feat: Initial commit!

This commit is contained in:
ΞTΞRNAL
2025-10-03 17:10:09 +05:30
parent b720b58886
commit 0676212a38
279 changed files with 449644 additions and 2215 deletions

View File

@@ -7,15 +7,13 @@ import java.io.ByteArrayOutputStream
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.kotlinAndroid)
alias(libs.plugins.compose.compiler)
id("kotlin-parcelize")
}
android {
namespace = rootProject.ext["applicationId"].toString()
compileSdk = 34
compileSdk = 36
buildFeatures {
aidl = true
compose = true
@@ -26,7 +24,7 @@ android {
versionCode = rootProject.ext["appVersionCode"].toString().toInt()
versionName = rootProject.ext["appVersionName"].toString()
minSdk = 28
targetSdk = 34
targetSdk = 36
multiDexEnabled = true
}
@@ -46,8 +44,6 @@ android {
}
flavorDimensions += "abi"
//noinspection ChromeOsAbiSupport
productFlavors {
packaging {
jniLibs {
@@ -62,25 +58,21 @@ android {
excludes += "META-INF/*.kotlin_module"
}
}
create("core") {
dimension = "abi"
}
create("armv8") {
ndk {
abiFilters += "arm64-v8a"
}
dimension = "abi"
}
create("armv7") {
ndk {
abiFilters += "armeabi-v7a"
}
dimension = "abi"
}
create("all") {
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
@@ -89,30 +81,30 @@ android {
}
}
properties["debug_flavor"]?.let {
android.productFlavors.find { it.name == it.toString()}?.setIsDefault(true)
}
applicationVariants.all {
outputs.map { it as BaseVariantOutputImpl }.forEach { outputVariant ->
outputVariant.outputFileName = when {
name.startsWith("core") -> "core.apk"
else -> "snapenhance_${rootProject.ext["appVersionName"]}-${outputVariant.name}.apk"
}
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
kotlinOptions {
jvmTarget = "21"
}
}
androidComponents {
onVariants { variant ->
val flavorName = variant.flavorName
if (properties["debug_flavor"] == flavorName) {
// variant.makeDefault.set(true) // This is no longer supported
}
variant.outputs.forEach { output ->
val variantOutput = output as com.android.build.api.variant.impl.VariantOutputImpl
variantOutput.outputFileName.set(
when {
variant.name.startsWith("core") -> "core.apk"
else -> "snapenhance_${rootProject.ext["appVersionName"]}-${variant.name}.apk"
}
)
}
}
onVariants(selector().withFlavor("abi", "core")) {
it.packaging.jniLibs.apply {
pickFirsts.set(listOf("**/lib${rootProject.ext["buildHash"]}.so"))
@@ -152,38 +144,60 @@ dependencies {
properties["debug_flavor"]?.let {
debugImplementation(libs.androidx.ui.tooling)
}
implementation("androidx.appcompat:appcompat:1.7.1")
implementation("com.google.android.material:material:1.13.0")
implementation(libs.androidx.material3)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.fetch)
// --- COMPOSE: explicit modern UI/Foundation for widthIn/wrapContentWidth ----
fullImplementation(libs.foundation)
fullImplementation(libs.ui)
fullImplementation(libs.foundation.layout)
// Animated navigation + transitions (Accompanist)
fullImplementation(libs.accompanist.navigation.animation)
}
afterEvaluate {
properties["debug_flavor"]?.toString()?.let { tasks.findByName("install${it.capitalized()}Debug") }?.doLast {
runCatching {
val devices = ByteArrayOutputStream().also {
exec {
commandLine("adb", "devices")
standardOutput = it
}
}.toString().lines().drop(1).mapNotNull {
line -> line.split("\t").firstOrNull()?.takeIf { it.isNotEmpty() }
}
runBlocking {
devices.forEach { device ->
launch {
exec {
commandLine("adb", "-s", device, "shell", "am", "force-stop", properties["debug_package_name"])
properties["debug_flavor"]?.toString()
?.let { flavor -> tasks.findByName("install${flavor.replaceFirstChar { it.uppercase() }}Debug") }
?.doLast {
runCatching {
val packageName = properties["debug_package_name"]?.toString() ?: return@runCatching
val devicesProcess = ProcessBuilder("adb", "devices")
.redirectErrorStream(true)
.start()
val devices = devicesProcess.inputStream.bufferedReader().useLines { lines ->
lines.drop(1)
.mapNotNull { line ->
line.split("\t").firstOrNull()?.takeIf { it.isNotEmpty() }
}
delay(500)
exec {
commandLine("adb", "-s", device, "shell", "am", "start", properties["debug_package_name"])
.toList()
}
devicesProcess.waitFor()
runBlocking {
devices.forEach { device ->
launch {
ProcessBuilder("adb", "-s", device, "shell", "am", "force-stop", packageName)
.redirectErrorStream(true)
.start()
.apply { waitFor() }
delay(500)
ProcessBuilder("adb", "-s", device, "shell", "am", "start", packageName)
.redirectErrorStream(true)
.start()
.apply { waitFor() }
}
}
}
}
}
}
}
properties["debug_flavor"]?.let {
configurations.all {
exclude(group = "androidx.profileinstaller", "profileinstaller")
}
}

View File

@@ -14,4 +14,8 @@
-keepclassmembers class * implements android.os.Parcelable {
public static final ** CREATOR;
}
}
# Prevent WorkManager from stripping generated Room database constructor
-keep class androidx.work.impl.WorkDatabase_Impl { *; }

View File

@@ -7,15 +7,11 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" tools:node="remove" tools:ignore="all" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" tools:node="remove" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" tools:node="remove" tools:ignore="all" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<queries>
<package android:name="com.snapchat.android" />
</queries>
<application
android:usesCleartextTraffic="true"
android:label="@string/app_name"
@@ -29,25 +25,24 @@
android:value="true" />
<meta-data
android:name="xposeddescription"
android:value="SnapEnhance by rhunk" />
android:value="PurrfectSnap by Eternal" />
<meta-data
android:name="xposedminversion"
android:value="93" />
<meta-data
android:name="xposedscope"
android:value="com.snapchat.android" />
<service
android:name=".bridge.BridgeService"
android:exported="true"
android:permission="com.snapchat.android.permission.UPDATE_STICKER_INDEX">
</service>
<activity
android:name=".ui.manager.MainActivity"
android:theme="@style/AppTheme"
android:launchMode="singleTask"
android:exported="true">
android:exported="true"
android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@@ -68,9 +63,7 @@
android:theme="@style/BiometricPromptTheme"
android:excludeFromRecents="true"
android:exported="true" />
<receiver android:name=".StreaksReminder" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="me.rhunk.snapenhance.fileprovider"
@@ -81,5 +74,4 @@
android:resource="@xml/provider_paths" />
</provider>
</application>
</manifest>
</manifest>

View File

@@ -70,51 +70,46 @@ class RemoteFileHandleManager(
mkdirs()
}
override fun getFileHandle(scope: String, name: String): FileHandle? {
override fun getFileHandle(scope: String, name: String): FileHandle? {
val fileHandleScope = FileHandleScope.fromValue(scope) ?: run {
context.log.error("invalid file handle scope: $scope", "FileHandleManager")
return null
}
when (fileHandleScope) {
return when (fileHandleScope) {
FileHandleScope.INTERNAL -> {
val fileHandleType = InternalFileHandleType.fromValue(name) ?: run {
context.log.error("invalid file handle name: $name", "FileHandleManager")
return null
}
return LocalFileHandle(
fileHandleType.resolve(context.androidContext)
)
LocalFileHandle(fileHandleType.resolve(context.androidContext))
}
FileHandleScope.LOCALE -> {
val foundLocale = context.androidContext.resources.assets.list("lang")?.firstOrNull {
it.startsWith(name)
}?.substringBefore(".") ?: return null
if (name == LocaleWrapper.DEFAULT_LOCALE) {
return AssetFileHandle(
AssetFileHandle(
context,
"lang/${LocaleWrapper.DEFAULT_LOCALE}.json"
)
} else {
AssetFileHandle(
context,
"lang/$foundLocale.json"
)
}
return AssetFileHandle(
context,
"lang/$foundLocale.json"
)
}
FileHandleScope.USER_IMPORT -> {
return LocalFileHandle(
LocalFileHandle(
File(userImportFolder, name.substringAfterLast("/"))
)
}
FileHandleScope.COMPOSER -> {
return AssetFileHandle(
AssetFileHandle(
context,
"composer/${name.substringAfterLast("/")}"
)
}
else -> return null
}
}
@@ -150,4 +145,5 @@ class RemoteFileHandleManager(
context.log.error("Failed to delete file: ${it.message}", it)
}.isSuccess
}
}
}

View File

@@ -48,11 +48,19 @@ import java.io.ByteArrayInputStream
import java.lang.ref.WeakReference
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import com.tonyodev.fetch2.Fetch
import com.tonyodev.fetch2.FetchConfiguration
class RemoteSideContext(
val androidContext: Context
) {
val fetch: Fetch by lazy {
val fetchConfiguration = FetchConfiguration.Builder(androidContext)
.setDownloadConcurrentLimit(3)
.build()
Fetch.getInstance(fetchConfiguration)
}
val coroutineScope = CoroutineScope(Dispatchers.IO)
private var _activity: WeakReference<ComponentActivity>? = null
@@ -64,11 +72,12 @@ class RemoteSideContext(
val sharedPreferences: SharedPreferences get() = androidContext.getSharedPreferences("prefs", 0)
val fileHandleManager = RemoteFileHandleManager(this)
val database = AppDatabase(this)
val trackerDataManager = me.rhunk.snapenhance.storage.TrackerDataManagerImpl(database)
val config = ModConfig(androidContext, constantLazyBridge { fileHandleManager })
val translation = LocaleWrapper(constantLazyBridge { fileHandleManager })
val mappings = MappingsWrapper(constantLazyBridge { fileHandleManager })
val taskManager = TaskManager(this)
val database = AppDatabase(this)
val streaksReminder = StreaksReminder(this)
val log = LogManager(this)
val scriptManager = RemoteScriptManager(this)
@@ -145,11 +154,12 @@ class RemoteSideContext(
val installationSummary by lazy {
InstallationSummary(
snapchatInfo = mappings.getSnapchatPackageInfo()?.let {
val packageName = requireNotNull(it.packageName) { "Package name cannot be null" }
SnapchatAppInfo(
packageName = it.packageName,
version = it.versionName,
packageName = packageName,
version = it.versionName ?: "unknown",
versionCode = it.longVersionCode,
isLSPatched = it.applicationInfo.appComponentFactory != CoreComponentFactory::class.java.name,
isLSPatched = it.applicationInfo?.appComponentFactory != CoreComponentFactory::class.java.name,
isSplitApk = it.splitNames?.isNotEmpty() ?: false
)
},
@@ -238,3 +248,4 @@ class RemoteSideContext(
androidContext.startActivity(intent)
}
}

View File

@@ -50,34 +50,28 @@ class BridgeService : Service() {
remoteSideContext.log.warn("Failed to sync $scope $id: Callback is dead")
return
}
val modDatabase = remoteSideContext.database
val database = remoteSideContext.database
val syncedObject = when (scope) {
SocialScope.FRIEND -> {
if (updateOnly && modDatabase.getFriendInfo(id) == null) return
if (updateOnly && database.getFriendInfo(id) == null) return
syncCallback.syncFriend(id)
}
SocialScope.GROUP -> {
if (updateOnly && modDatabase.getGroupInfo(id) == null) return
if (updateOnly && database.getGroupInfo(id) == null) return
syncCallback.syncGroup(id)
}
else -> null
}
if (syncedObject == null) {
} ?: run {
remoteSideContext.log.warn("Failed to sync $scope $id")
return
}
when (scope) {
SocialScope.FRIEND -> {
toParcelable<MessagingFriendInfo>(syncedObject)?.let {
modDatabase.syncFriend(it)
}
toParcelable<MessagingFriendInfo>(syncedObject)?.let { database.syncFriend(it) }
}
SocialScope.GROUP -> {
toParcelable<MessagingGroupInfo>(syncedObject)?.let {
modDatabase.syncGroupInfo(it)
}
toParcelable<MessagingGroupInfo>(syncedObject)?.let { database.syncGroupInfo(it) }
}
}
}.onFailure {
@@ -247,3 +241,4 @@ class BridgeService : Service() {
}
}
}

View File

@@ -4,7 +4,13 @@ import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.bridge.e2ee.E2eeInterface
import me.rhunk.snapenhance.bridge.e2ee.EncryptionResult
import me.rhunk.snapenhance.core.util.EvictingMap
import org.bouncycastle.pqc.crypto.crystals.kyber.*
import org.bouncycastle.pqc.crypto.crystals.kyber.KyberKEMExtractor
import org.bouncycastle.pqc.crypto.crystals.kyber.KyberKEMGenerator
import org.bouncycastle.pqc.crypto.crystals.kyber.KyberKeyGenerationParameters
import org.bouncycastle.pqc.crypto.crystals.kyber.KyberKeyPairGenerator
import org.bouncycastle.pqc.crypto.crystals.kyber.KyberParameters
import org.bouncycastle.pqc.crypto.crystals.kyber.KyberPrivateKeyParameters
import org.bouncycastle.pqc.crypto.crystals.kyber.KyberPublicKeyParameters
import java.io.File
import java.security.MessageDigest
import java.security.SecureRandom
@@ -163,4 +169,4 @@ class E2EEImplementation (
return null
}.getOrNull()
}
}
}

View File

@@ -145,8 +145,11 @@ class RemoteScriptManager(
if (!response.isSuccessful) {
throw Exception("Failed to fetch script. Code: ${response.code}")
}
response.body.byteStream().use { inputStream ->
val bufferedInputStream = inputStream.buffered()
val inputStream = response.body?.byteStream() ?: throw Exception("Response body is null or cannot be read")
inputStream.use {
val bufferedInputStream = it.buffered()
bufferedInputStream.mark(0)
val moduleInfo = bufferedInputStream.bufferedReader().readModuleInfo()
bufferedInputStream.reset()
@@ -173,7 +176,7 @@ class RemoteScriptManager(
if (!response.isSuccessful) {
return@runCatching null
}
response.body.byteStream().use { inputStream ->
response.body?.byteStream()?.use { inputStream ->
val reader = inputStream.buffered().bufferedReader()
val moduleInfo = reader.readModuleInfo()
moduleInfo.takeIf {

View File

@@ -63,6 +63,7 @@ class AppDatabase(
"id INTEGER PRIMARY KEY AUTOINCREMENT",
"enabled BOOLEAN DEFAULT 1",
"name VARCHAR",
"author VARCHAR",
),
"tracker_scopes" to listOf(
"id INTEGER PRIMARY KEY AUTOINCREMENT",
@@ -104,7 +105,9 @@ class AppDatabase(
"content TEXT",
),
"repositories" to listOf(
"url VARCHAR PRIMARY KEY",
"url VARCHAR",
"type VARCHAR",
"PRIMARY KEY (url, type)"
),
"notes" to listOf(
"id CHAR(36) PRIMARY KEY",

View File

@@ -7,7 +7,7 @@ import me.rhunk.snapenhance.common.data.MessagingRuleType
import me.rhunk.snapenhance.common.util.ktx.getInteger
import me.rhunk.snapenhance.common.util.ktx.getLongOrNull
import me.rhunk.snapenhance.common.util.ktx.getStringOrNull
import java.io.Serializable
fun AppDatabase.getGroups(): List<MessagingGroupInfo> {
return database.rawQuery("SELECT * FROM groups", null).use { cursor ->
@@ -20,7 +20,10 @@ fun AppDatabase.getGroups(): List<MessagingGroupInfo> {
}
fun AppDatabase.getFriends(descOrder: Boolean = false): List<MessagingFriendInfo> {
return database.rawQuery("SELECT * FROM friends LEFT OUTER JOIN streaks ON friends.userId = streaks.id ORDER BY id ${if (descOrder) "DESC" else "ASC"}", null).use { cursor ->
return database.rawQuery(
"SELECT * FROM friends LEFT OUTER JOIN streaks ON friends.userId = streaks.id ORDER BY id ${if (descOrder) "DESC" else "ASC"}",
null
).use { cursor ->
val friends = mutableListOf<MessagingFriendInfo>()
while (cursor.moveToNext()) {
runCatching {
@@ -33,15 +36,17 @@ fun AppDatabase.getFriends(descOrder: Boolean = false): List<MessagingFriendInfo
}
}
fun AppDatabase.syncGroupInfo(conversationInfo: MessagingGroupInfo) {
executeAsync {
try {
database.execSQL("INSERT OR REPLACE INTO groups (conversationId, name, participantsCount) VALUES (?, ?, ?)", arrayOf(
conversationInfo.conversationId,
conversationInfo.name,
conversationInfo.participantsCount
))
database.execSQL(
"INSERT OR REPLACE INTO groups (conversationId, name, participantsCount) VALUES (?, ?, ?)",
arrayOf(
conversationInfo.conversationId,
conversationInfo.name,
conversationInfo.participantsCount
)
)
} catch (e: Exception) {
throw e
}
@@ -53,7 +58,7 @@ fun AppDatabase.syncFriend(friend: MessagingFriendInfo) {
try {
database.execSQL(
"INSERT OR REPLACE INTO friends (userId, dmConversationId, displayName, mutableUsername, bitmojiId, selfieId) VALUES (?, ?, ?, ?, ?, ?)",
arrayOf(
arrayOf<Any?>(
friend.userId,
friend.dmConversationId,
friend.displayName,
@@ -65,13 +70,15 @@ fun AppDatabase.syncFriend(friend: MessagingFriendInfo) {
//sync streaks
friend.streaks?.takeIf { it.length > 0 }?.also {
val streaks = getFriendStreaks(friend.userId)
database.execSQL("INSERT OR REPLACE INTO streaks (id, notify, expirationTimestamp, length) VALUES (?, ?, ?, ?)", arrayOf(
friend.userId,
streaks?.notify != false,
it.expirationTimestamp,
it.length
))
database.execSQL(
"INSERT OR REPLACE INTO streaks (id, notify, expirationTimestamp, length) VALUES (?, ?, ?, ?)",
arrayOf<Any?>(
friend.userId,
streaks?.notify != false,
it.expirationTimestamp,
it.length
)
)
} ?: database.execSQL("DELETE FROM streaks WHERE id = ?", arrayOf(friend.userId))
} catch (e: Exception) {
throw e
@@ -79,16 +86,18 @@ fun AppDatabase.syncFriend(friend: MessagingFriendInfo) {
}
}
fun AppDatabase.getRules(targetUuid: String): List<MessagingRuleType> {
return database.rawQuery("SELECT type FROM rules WHERE targetUuid = ?", arrayOf(
targetUuid
)).use { cursor ->
return database.rawQuery(
"SELECT type FROM rules WHERE targetUuid = ?", arrayOf(targetUuid)
).use { cursor ->
val rules = mutableListOf<MessagingRuleType>()
while (cursor.moveToNext()) {
runCatching {
rules.add(MessagingRuleType.getByName(cursor.getStringOrNull("type")!!) ?: return@runCatching)
cursor.getStringOrNull("type")?.let {
MessagingRuleType.getByName(it)
}?.let { ruleType ->
rules.add(ruleType)
}
}.onFailure {
context.log.error("Failed to parse rule", it)
}
@@ -100,28 +109,33 @@ fun AppDatabase.getRules(targetUuid: String): List<MessagingRuleType> {
fun AppDatabase.setRule(targetUuid: String, type: String, enabled: Boolean) {
executeAsync {
if (enabled) {
database.execSQL("INSERT OR REPLACE INTO rules (targetUuid, type) VALUES (?, ?)", arrayOf(
targetUuid,
type
))
database.execSQL(
"INSERT OR REPLACE INTO rules (targetUuid, type) VALUES (?, ?)",
arrayOf(targetUuid, type)
)
} else {
database.execSQL("DELETE FROM rules WHERE targetUuid = ? AND type = ?", arrayOf(
targetUuid,
type
))
database.execSQL(
"DELETE FROM rules WHERE targetUuid = ? AND type = ?",
arrayOf(targetUuid, type)
)
}
}
}
fun AppDatabase.getFriendInfo(userId: String): MessagingFriendInfo? {
return database.rawQuery("SELECT * FROM friends LEFT OUTER JOIN streaks ON friends.userId = streaks.id WHERE userId = ?", arrayOf(userId)).use { cursor ->
return database.rawQuery(
"SELECT * FROM friends LEFT OUTER JOIN streaks ON friends.userId = streaks.id WHERE userId = ?",
arrayOf(userId)
).use { cursor ->
if (!cursor.moveToFirst()) return@use null
MessagingFriendInfo.fromCursor(cursor)
}
}
fun AppDatabase.findFriend(conversationId: String): MessagingFriendInfo? {
return database.rawQuery("SELECT * FROM friends WHERE dmConversationId = ?", arrayOf(conversationId)).use { cursor ->
return database.rawQuery(
"SELECT * FROM friends WHERE dmConversationId = ?", arrayOf(conversationId)
).use { cursor ->
if (!cursor.moveToFirst()) return@use null
MessagingFriendInfo.fromCursor(cursor)
}
@@ -143,14 +157,18 @@ fun AppDatabase.deleteGroup(conversationId: String) {
}
fun AppDatabase.getGroupInfo(conversationId: String): MessagingGroupInfo? {
return database.rawQuery("SELECT * FROM groups WHERE conversationId = ?", arrayOf(conversationId)).use { cursor ->
return database.rawQuery(
"SELECT * FROM groups WHERE conversationId = ?", arrayOf(conversationId)
).use { cursor ->
if (!cursor.moveToFirst()) return@use null
MessagingGroupInfo.fromCursor(cursor)
}
}
fun AppDatabase.getFriendStreaks(userId: String): FriendStreaks? {
return database.rawQuery("SELECT * FROM streaks WHERE id = ?", arrayOf(userId)).use { cursor ->
return database.rawQuery(
"SELECT * FROM streaks WHERE id = ?", arrayOf(userId)
).use { cursor ->
if (!cursor.moveToFirst()) return@use null
FriendStreaks(
notify = cursor.getInteger("notify") == 1,
@@ -162,18 +180,20 @@ fun AppDatabase.getFriendStreaks(userId: String): FriendStreaks? {
fun AppDatabase.setFriendStreaksNotify(userId: String, notify: Boolean) {
executeAsync {
database.execSQL("UPDATE streaks SET notify = ? WHERE id = ?", arrayOf(
if (notify) 1 else 0,
userId
))
database.execSQL(
"UPDATE streaks SET notify = ? WHERE id = ?",
arrayOf<Any?>(if (notify) 1 else 0, userId)
)
}
}
fun AppDatabase.getRuleIds(type: String): MutableList<String> {
return database.rawQuery("SELECT targetUuid FROM rules WHERE type = ?", arrayOf(type)).use { cursor ->
return database.rawQuery(
"SELECT targetUuid FROM rules WHERE type = ?", arrayOf(type)
).use { cursor ->
val ruleIds = mutableListOf<String>()
while (cursor.moveToNext()) {
ruleIds.add(cursor.getStringOrNull("targetUuid")!!)
cursor.getStringOrNull("targetUuid")?.let { ruleIds.add(it) }
}
ruleIds
}
@@ -184,4 +204,3 @@ fun AppDatabase.clearRuleIds(type: String) {
database.execSQL("DELETE FROM rules WHERE type = ?", arrayOf(type))
}
}

View File

@@ -2,12 +2,14 @@ package me.rhunk.snapenhance.storage
import me.rhunk.snapenhance.common.util.ktx.getStringOrNull
fun AppDatabase.getQuickTiles(): List<String> {
return database.rawQuery("SELECT `key` FROM quick_tiles ORDER BY position ASC", null).use { cursor ->
val keys = mutableListOf<String>()
while (cursor.moveToNext()) {
keys.add(cursor.getStringOrNull("key") ?: continue)
val key = cursor.getStringOrNull("key")
if (key != null) {
keys.add(key)
}
}
keys
}
@@ -17,10 +19,10 @@ fun AppDatabase.setQuickTiles(keys: List<String>) {
executeAsync {
database.execSQL("DELETE FROM quick_tiles")
keys.forEachIndexed { index, key ->
database.execSQL("INSERT INTO quick_tiles (`key`, position) VALUES (?, ?)", arrayOf(
key,
index
))
database.execSQL(
"INSERT INTO quick_tiles (`key`, position) VALUES (?, ?)",
arrayOf<Any>(key, index)
)
}
}
}
}

View File

@@ -6,9 +6,9 @@ import kotlinx.coroutines.runBlocking
import me.rhunk.snapenhance.common.util.ktx.getStringOrNull
fun AppDatabase.getRepositories(): List<String> {
fun AppDatabase.getRepositories(type: String): List<String> {
return runBlocking(executor.asCoroutineDispatcher()) {
database.rawQuery("SELECT url FROM repositories", null).use { cursor ->
database.rawQuery("SELECT url FROM repositories WHERE type = ?", arrayOf(type)).use { cursor ->
val repos = mutableListOf<String>()
while (cursor.moveToNext()) {
repos.add(cursor.getStringOrNull("url") ?: continue)
@@ -18,16 +18,17 @@ fun AppDatabase.getRepositories(): List<String> {
}
}
fun AppDatabase.removeRepo(url: String) {
fun AppDatabase.removeRepo(type: String, url: String) {
runBlocking(executor.asCoroutineDispatcher()) {
database.delete("repositories", "url = ?", arrayOf(url))
database.delete("repositories", "url = ? AND type = ?", arrayOf(url, type))
}
}
fun AppDatabase.addRepo(url: String) {
fun AppDatabase.addRepo(type: String, url: String) {
runBlocking(executor.asCoroutineDispatcher()) {
database.insert("repositories", null, ContentValues().apply {
put("url", url)
put("type", type)
})
}
}

View File

@@ -13,10 +13,9 @@ import me.rhunk.snapenhance.common.util.ktx.getLongOrNull
import me.rhunk.snapenhance.common.util.ktx.getStringOrNull
import kotlin.coroutines.suspendCoroutine
fun AppDatabase.clearTrackerRules() {
runBlocking {
suspendCoroutine { continuation ->
suspendCoroutine<Unit> { continuation ->
executeAsync {
database.execSQL("DELETE FROM tracker_rules")
database.execSQL("DELETE FROM tracker_rules_events")
@@ -33,12 +32,13 @@ fun AppDatabase.deleteTrackerRule(ruleId: Int) {
}
}
fun AppDatabase.newTrackerRule(name: String = "Custom Rule"): Int {
fun AppDatabase.newTrackerRule(name: String = "Custom Rule", author: String? = null): Int {
return runBlocking {
suspendCoroutine { continuation ->
suspendCoroutine<Int> { continuation ->
executeAsync {
val id = database.insert("tracker_rules", null, ContentValues().apply {
put("name", name)
put("author", author)
})
continuation.resumeWith(Result.success(id.toInt()))
}
@@ -54,10 +54,10 @@ fun AppDatabase.addOrUpdateTrackerRuleEvent(
actions: List<TrackerRuleAction>
): Int? {
return runBlocking {
suspendCoroutine { continuation ->
suspendCoroutine<Int?> { continuation ->
executeAsync {
val id = if (ruleEventId != null) {
database.execSQL("UPDATE tracker_rules_events SET params = ?, actions = ? WHERE id = ?", arrayOf(
database.execSQL("UPDATE tracker_rules_events SET params = ?, actions = ? WHERE id = ?", arrayOf<Any?>(
context.gson.toJson(params),
context.gson.toJson(actions.map { it.key }),
ruleEventId
@@ -85,7 +85,6 @@ fun AppDatabase.deleteTrackerRuleEvent(eventId: Int) {
fun AppDatabase.getTrackerRulesDesc(): List<TrackerRule> {
val rules = mutableListOf<TrackerRule>()
database.rawQuery("SELECT * FROM tracker_rules ORDER BY id DESC", null).use { cursor ->
while (cursor.moveToNext()) {
rules.add(
@@ -93,11 +92,11 @@ fun AppDatabase.getTrackerRulesDesc(): List<TrackerRule> {
id = cursor.getInteger("id"),
enabled = cursor.getInteger("enabled") == 1,
name = cursor.getStringOrNull("name") ?: "",
author = cursor.getStringOrNull("author")
)
)
}
}
return rules
}
@@ -108,6 +107,19 @@ fun AppDatabase.getTrackerRule(ruleId: Int): TrackerRule? {
id = cursor.getInteger("id"),
enabled = cursor.getInteger("enabled") == 1,
name = cursor.getStringOrNull("name") ?: "",
author = cursor.getStringOrNull("author")
)
}
}
fun AppDatabase.getTrackerRuleByName(name: String): TrackerRule? {
return database.rawQuery("SELECT * FROM tracker_rules WHERE name = ?", arrayOf(name)).use { cursor ->
if (!cursor.moveToFirst()) return@use null
TrackerRule(
id = cursor.getInteger("id"),
enabled = cursor.getInteger("enabled") == 1,
name = cursor.getStringOrNull("name") ?: "",
author = cursor.getStringOrNull("author")
)
}
}
@@ -118,9 +130,15 @@ fun AppDatabase.setTrackerRuleName(ruleId: Int, name: String) {
}
}
fun AppDatabase.setTrackerRuleAuthor(ruleId: Int, author: String) {
executeAsync {
database.execSQL("UPDATE tracker_rules SET author = ? WHERE id = ?", arrayOf(author, ruleId))
}
}
fun AppDatabase.setTrackerRuleState(ruleId: Int, enabled: Boolean) {
executeAsync {
database.execSQL("UPDATE tracker_rules SET enabled = ? WHERE id = ?", arrayOf(if (enabled) 1 else 0, ruleId))
database.execSQL("UPDATE tracker_rules SET enabled = ? WHERE id = ?", arrayOf<Any?>(if (enabled) 1 else 0, ruleId))
}
}
@@ -128,10 +146,12 @@ fun AppDatabase.getTrackerEvents(ruleId: Int): List<TrackerRuleEvent> {
val events = mutableListOf<TrackerRuleEvent>()
database.rawQuery("SELECT * FROM tracker_rules_events WHERE rule_id = ?", arrayOf(ruleId.toString())).use { cursor ->
while (cursor.moveToNext()) {
val eventType = cursor.getStringOrNull("event_type")
if (eventType == null) continue
events.add(
TrackerRuleEvent(
id = cursor.getInteger("id"),
eventType = cursor.getStringOrNull("event_type") ?: continue,
eventType = eventType,
enabled = cursor.getInteger("flags") == 1,
params = context.gson.fromJson(cursor.getStringOrNull("params") ?: "{}", TrackerRuleActionParams::class.java),
actions = context.gson.fromJson(cursor.getStringOrNull("actions") ?: "[]", JsonArray::class.java).mapNotNull {
@@ -146,7 +166,8 @@ fun AppDatabase.getTrackerEvents(ruleId: Int): List<TrackerRuleEvent> {
fun AppDatabase.getTrackerEvents(eventType: String): Map<TrackerRuleEvent, TrackerRule> {
val events = mutableMapOf<TrackerRuleEvent, TrackerRule>()
database.rawQuery("SELECT tracker_rules_events.id as event_id, tracker_rules_events.params as event_params," +
database.rawQuery(
"SELECT tracker_rules_events.id as event_id, tracker_rules_events.params as event_params," +
"tracker_rules_events.actions, tracker_rules_events.flags, tracker_rules_events.event_type, tracker_rules.name, tracker_rules.id as rule_id " +
"FROM tracker_rules_events " +
"INNER JOIN tracker_rules " +
@@ -154,14 +175,17 @@ fun AppDatabase.getTrackerEvents(eventType: String): Map<TrackerRuleEvent, Track
"WHERE event_type = ? AND tracker_rules.enabled = 1", arrayOf(eventType)
).use { cursor ->
while (cursor.moveToNext()) {
val name = cursor.getStringOrNull("name") ?: ""
val trackerRule = TrackerRule(
id = cursor.getInteger("rule_id"),
enabled = true,
name = cursor.getStringOrNull("name") ?: "",
name = name,
)
val curEventType = cursor.getStringOrNull("event_type")
if (curEventType == null) continue
val trackerRuleEvent = TrackerRuleEvent(
id = cursor.getInteger("event_id"),
eventType = cursor.getStringOrNull("event_type") ?: continue,
eventType = curEventType,
enabled = cursor.getInteger("flags") == 1,
params = context.gson.fromJson(cursor.getStringOrNull("event_params") ?: "{}", TrackerRuleActionParams::class.java),
actions = context.gson.fromJson(cursor.getStringOrNull("actions") ?: "[]", JsonArray::class.java).mapNotNull {
@@ -178,20 +202,24 @@ fun AppDatabase.setRuleTrackerScopes(ruleId: Int, type: TrackerScopeType, scopes
executeAsync {
database.execSQL("DELETE FROM tracker_scopes WHERE rule_id = ?", arrayOf(ruleId))
scopes.forEach { scopeId ->
database.execSQL("INSERT INTO tracker_scopes (rule_id, scope_type, scope_id) VALUES (?, ?, ?)", arrayOf(
ruleId,
type.key,
scopeId
))
database.execSQL(
"INSERT INTO tracker_scopes (rule_id, scope_type, scope_id) VALUES (?, ?, ?)",
arrayOf<Any?>(ruleId, type.key, scopeId)
)
}
}
}
fun AppDatabase.getRuleTrackerScopes(ruleId: Int, limit: Int = Int.MAX_VALUE): Map<String, TrackerScopeType> {
val scopes = mutableMapOf<String, TrackerScopeType>()
database.rawQuery("SELECT * FROM tracker_scopes WHERE rule_id = ? LIMIT ?", arrayOf(ruleId.toString(), limit.toString())).use { cursor ->
database.rawQuery(
"SELECT * FROM tracker_scopes WHERE rule_id = ? LIMIT ?", arrayOf(ruleId.toString(), limit.toString())
).use { cursor ->
while (cursor.moveToNext()) {
scopes[cursor.getStringOrNull("scope_id") ?: continue] = TrackerScopeType.entries.find { it.key == cursor.getStringOrNull("scope_type") } ?: continue
val scopeId = cursor.getStringOrNull("scope_id") ?: continue
val scopeTypeKey = cursor.getStringOrNull("scope_type")
val type = TrackerScopeType.entries.find { it.key == scopeTypeKey } ?: continue
scopes[scopeId] = type
}
}
return scopes
@@ -199,21 +227,19 @@ fun AppDatabase.getRuleTrackerScopes(ruleId: Int, limit: Int = Int.MAX_VALUE): M
fun AppDatabase.updateFriendScore(userId: String, score: Long): Long {
return runBlocking {
suspendCoroutine { continuation ->
suspendCoroutine<Long> { continuation ->
executeAsync {
val currentScore = database.rawQuery("SELECT score FROM friend_scores WHERE userId = ?", arrayOf(userId)).use { cursor ->
if (!cursor.moveToFirst()) return@use null
cursor.getLongOrNull("score")
}
if (currentScore != null) {
database.execSQL("UPDATE friend_scores SET score = ? WHERE userId = ?", arrayOf(score, userId))
} else {
database.execSQL("INSERT INTO friend_scores (userId, score) VALUES (?, ?)", arrayOf(userId, score))
}
continuation.resumeWith(Result.success(currentScore ?: -1))
}
}
}
}
}

View File

@@ -0,0 +1,59 @@
package me.rhunk.snapenhance.storage
import me.rhunk.snapenhance.common.data.ExportedTrackerData
import me.rhunk.snapenhance.common.data.TrackerDataManager
import me.rhunk.snapenhance.storage.AppDatabase
class TrackerDataManagerImpl(private val db: AppDatabase) : TrackerDataManager {
override fun getExportedTrackerData(): ExportedTrackerData {
return ExportedTrackerData(
type = me.rhunk.snapenhance.common.data.ExportType.BULK,
rules = db.getTrackerRulesDesc().map { rule ->
rule.copy(
events = db.getTrackerEvents(rule.id),
scopes = db.getRuleTrackerScopes(rule.id)
)
}
)
}
override fun getExportedTrackerData(ruleId: Int): ExportedTrackerData? {
return db.getTrackerRule(ruleId)?.let {
ExportedTrackerData(
type = me.rhunk.snapenhance.common.data.ExportType.SINGLE,
rules = listOf(it.copy(
events = db.getTrackerEvents(it.id),
scopes = db.getRuleTrackerScopes(it.id)
))
)
}
}
override fun importTrackerData(data: ExportedTrackerData) {
if (data.type == me.rhunk.snapenhance.common.data.ExportType.BULK) {
db.clearTrackerRules()
}
data.rules.forEach { rule ->
if (db.getTrackerRuleByName(rule.name) != null) {
return@forEach
}
val ruleId = db.newTrackerRule(rule.name, rule.author)
db.setTrackerRuleState(ruleId, rule.enabled)
rule.events?.forEach { event ->
db.addOrUpdateTrackerRuleEvent(
ruleId = ruleId,
eventType = event.eventType,
params = event.params,
actions = event.actions
)
}
rule.scopes?.let { scopes ->
if (scopes.isNotEmpty()) {
val scopeType = scopes.values.first()
val scopeIds = scopes.keys.toList()
db.setRuleTrackerScopes(ruleId, scopeType, scopeIds)
}
}
}
}
}

View File

@@ -0,0 +1,74 @@
package me.rhunk.snapenhance.task
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.work.CoroutineWorker
import android.Manifest
import androidx.core.content.ContextCompat
import android.content.pm.PackageManager
import androidx.work.WorkerParameters
import me.rhunk.snapenhance.R
import me.rhunk.snapenhance.ui.manager.MainActivity
import me.rhunk.snapenhance.ui.manager.data.Updater
class UpdateCheckWorker(
private val appContext: Context,
workerParams: WorkerParameters
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
return try {
val latestRelease = Updater.latestRelease
if (latestRelease != null) {
showUpdateNotification(latestRelease.versionName)
}
Result.success()
} catch (e: Exception) {
Result.failure()
}
}
private fun showUpdateNotification(versionName: String) {
val channelId = "snapenhance_updates"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val name = "SnapEnhance Updates"
val descriptionText = "Notifications for SnapEnhance updates"
val importance = NotificationManager.IMPORTANCE_DEFAULT
val channel = NotificationChannel(channelId, name, importance).apply {
description = descriptionText
}
val notificationManager: NotificationManager =
appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
val intent = Intent(appContext, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent: PendingIntent = PendingIntent.getActivity(appContext, 0, intent, PendingIntent.FLAG_IMMUTABLE)
val builder = NotificationCompat.Builder(appContext, channelId)
.setSmallIcon(R.drawable.launcher_icon_monochrome)
.setContentTitle("PurrfectSnap Update Available")
.setContentText("Version $versionName is now available.")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(appContext, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
// Cannot request permission from a worker. The user must grant it from the app's settings.
return
}
}
with(NotificationManagerCompat.from(appContext)) {
notify(1, builder.build())
}
}
}

View File

@@ -1,18 +1,42 @@
@file:OptIn(androidx.compose.animation.ExperimentalAnimationApi::class)
package me.rhunk.snapenhance.ui.manager
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.remember
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.foundation.background
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.navigationBars
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.SharedContextHolder
import me.rhunk.snapenhance.common.ui.AppMaterialTheme
import me.rhunk.snapenhance.common.ui.ThemeMode
import me.rhunk.snapenhance.common.ui.ThemePreferences
import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper
class MainActivity : ComponentActivity() {
private lateinit var navController: NavHostController
@@ -24,7 +48,7 @@ class MainActivity : ComponentActivity() {
intent.getStringExtra("route")?.let { route ->
navController.popBackStack()
navController.navigate(route) {
popUpTo(navController.graph.findStartDestination().id){
popUpTo(navController.graph.findStartDestination().id) {
inclusive = true
}
}
@@ -32,32 +56,119 @@ class MainActivity : ComponentActivity() {
}
override fun onCreate(savedInstanceState: Bundle?) {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
super.onCreate(savedInstanceState)
managerContext = SharedContextHolder.remote(this).apply {
activity = this@MainActivity
checkForRequirements()
}
val routes = Routes(managerContext)
routes.activityLauncher = ActivityLauncherHelper(this)
routes.getRoutes().forEach { it.init() }
setContent {
val context = LocalContext.current
// ThemeMode is tracked directly
val themeMode by ThemePreferences.getThemeModeFlow(context).collectAsState(initial = ThemeMode.SYSTEM)
// USE THE CORRECT CONTROLLER (not accompanist):
navController = rememberNavController()
val navigation = remember {
Navigation(managerContext, navController, routes.also {
it.navController = navController
})
}
val startDestination = remember { intent.getStringExtra("route") ?: routes.home.routeInfo.id }
AppMaterialTheme {
val startDestination = remember {
intent.getStringExtra("route") ?: run {
val def = managerContext.sharedPreferences.getString("manager_default_tab", "home") ?: "home"
val allowed = setOf("tasks", "features", "home", "social", "scripts")
if (def in allowed) def else "home"
}
}
AppMaterialTheme(themeMode = themeMode) {
val background = MaterialTheme.colorScheme.background
val isLight = background.luminance() > 0.5f
val view = LocalView.current
@Suppress("DEPRECATION")
SideEffect {
val window = (view.context as Activity).window
// Modern coloring:
WindowCompat.setDecorFitsSystemWindows(window, false)
window.statusBarColor = Color.Transparent.toArgb()
window.navigationBarColor = Color.Transparent.toArgb()
val insetsController = WindowInsetsControllerCompat(window, window.decorView)
insetsController.isAppearanceLightStatusBars = isLight
insetsController.isAppearanceLightNavigationBars = isLight
}
// Floating bottom bar height and vertical spacing so floating action buttons and scrolling content remain readable:
val bottomPadding = 80.dp + 16.dp +
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
routes.bottomPadding = bottomPadding
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val fullscreenRoutes = remember {
listOf(
Routes.CONFIG_IMPORT_CONFIRMATION_ROUTE,
Routes.CONFIG_EXPORT_SUMMARY_ROUTE,
Routes.FRIEND_TRACKER_CONFIG_EXPORT_ROUTE,
Routes.FRIEND_TRACKER_CONFIG_IMPORT_ROUTE
)
}
val isFullscreen = currentRoute in fullscreenRoutes
Scaffold(
modifier = Modifier.fillMaxSize(),
containerColor = MaterialTheme.colorScheme.background,
topBar = { navigation.TopBar() },
bottomBar = { navigation.BottomBar() },
floatingActionButton = { navigation.FloatingActionButton() }
) { innerPadding -> navigation.Content(innerPadding, startDestination) }
topBar = {
if (!isFullscreen) {
navigation.TopBar()
}
},
floatingActionButton = {
if (!isFullscreen) {
Box(Modifier.padding(bottom = bottomPadding)) {
navigation.Fab()
}
}
},
// Disable automatic padding so content can draw behind the bottom bar
contentWindowInsets = WindowInsets(0, 0, 0, 0)
) { innerPadding ->
Box(Modifier.fillMaxSize()) {
val contentPadding = if (!isFullscreen) {
PaddingValues(
top = innerPadding.calculateTopPadding(),
start = innerPadding.calculateStartPadding(LayoutDirection.Ltr),
end = innerPadding.calculateEndPadding(LayoutDirection.Ltr),
bottom = innerPadding.calculateBottomPadding()
)
} else {
PaddingValues(0.dp)
}
navigation.NavContent(contentPadding, startDestination)
if (!isFullscreen) {
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.height(routes.bottomPadding)
.background(
brush = Brush.verticalGradient(
colors = listOf(
Color.Transparent,
MaterialTheme.colorScheme.background
)
)
)
)
Box(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
) {
navigation.FloatingBottomBar()
}
}
}
}
}
}
}

View File

@@ -1,145 +1,565 @@
package me.rhunk.snapenhance.ui.manager
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.*
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.RadioButton
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.lerp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.navigation
import androidx.navigation.compose.navigation
import me.rhunk.snapenhance.RemoteSideContext
import kotlin.math.round
import kotlin.math.PI
import kotlin.math.sin
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalFoundationApi::class,
ExperimentalLayoutApi::class,
androidx.compose.animation.ExperimentalAnimationApi::class
)
class Navigation(
private val context: RemoteSideContext,
private val navController: NavHostController,
val routes: Routes = Routes(context).also {
it.navController = navController
}
){
val routes: Routes = Routes(context).also { it.navController = navController }
) {
var openBottomBarCustomization by mutableStateOf(false)
@Composable
fun TopBar() {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = remember(navBackStackEntry) { routes.getCurrentRoute(navBackStackEntry) }
val canGoBack = remember(navBackStackEntry) { currentRoute?.let {
!it.routeInfo.primary || it.routeInfo.childIds.contains(routes.currentDestination)
} == true }
TopAppBar(title = {
currentRoute?.apply {
title?.invoke() ?: routeInfo.translatedKey?.value?.let {
Text(text = it)
if (currentRoute?.routeInfo?.hasOwnTopBar == true) return
val canGoBack = remember(navBackStackEntry) {
currentRoute?.let { !it.routeInfo.primary || it.routeInfo.childIds.contains(routes.currentDestination) } == true
}
TopAppBar(
title = {
currentRoute?.apply {
title?.invoke() ?: routeInfo.translatedKey?.value?.let { Text(it) }
}
}
}, navigationIcon = {
val backButtonAnimation by animateFloatAsState(if (canGoBack) 1f else 0f,
label = "backButtonAnimation"
)
Box(
modifier = Modifier
.graphicsLayer { alpha = backButtonAnimation }
.width(lerp(0.dp, 48.dp, backButtonAnimation))
.height(48.dp)
) {
IconButton(
onClick = {
if (canGoBack) {
navController.popBackStack()
}
}
},
navigationIcon = {
val backButtonAnimation by animateFloatAsState(if (canGoBack) 1f else 0f, label = "backButton")
Box(
modifier = Modifier
.graphicsLayer { alpha = backButtonAnimation }
.width(lerp(0.dp, 48.dp, backButtonAnimation))
.height(48.dp)
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
IconButton(onClick = { if (canGoBack) navController.popBackStack() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
}
},
actions = {
currentRoute?.topBarActions?.invoke(this)
if (currentRoute?.routeInfo?.id == routes.settings.routeInfo.id) {
IconButton(onClick = { openBottomBarCustomization = true }) {
Icon(Icons.Filled.Tune, contentDescription = null)
}
}
}
}, actions = {
currentRoute?.topBarActions?.invoke(this)
})
)
}
@Composable
fun BottomBar() {
fun FloatingBottomBar() {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = remember(navBackStackEntry) { routes.getCurrentRoute(navBackStackEntry) }
val primaryRoutes = remember { routes.getRoutes().filter { it.routeInfo.showInNavBar } }
NavigationBar {
primaryRoutes.forEach { route ->
NavigationBarItem(
alwaysShowLabel = true,
icon = {
Icon(imageVector = route.routeInfo.icon, contentDescription = null)
},
label = {
Text(
textAlign = TextAlign.Center,
softWrap = false,
fontSize = 12.sp,
modifier = Modifier.wrapContentWidth(unbounded = true),
text = remember(context.translation.loadedLocale) { context.translation["manager.routes.${route.routeInfo.key.substringBefore("/")}"] },
)
},
selected = currentRoute == route,
onClick = {
route.navigateReset()
val availableRoutes = remember {
listOf(routes.tasks, routes.features, routes.home, routes.social, routes.scripting, routes.friendTracker)
}
val availableRouteMap = remember(availableRoutes) { availableRoutes.associateBy { it.routeInfo.id } }
val prefs = remember { context.sharedPreferences }
val defaultOrder = remember { listOf("tasks", "features", "home", "social", "scripts") }
fun loadSelected(): List<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] } }
Box(
Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 8.dp)
.navigationBarsPadding(),
contentAlignment = Alignment.BottomCenter
) {
val baseItemWidth = 92.dp
val containerPadding = 24.dp
val targetBarWidth = if (selectedRoutes.size < 5) baseItemWidth * selectedRoutes.size.toFloat() + containerPadding else null
val animatedBarWidth by animateDpAsState(targetValue = targetBarWidth ?: 0.dp, label = "barWidth")
Surface(
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)),
modifier = Modifier
.then(if (targetBarWidth != null) Modifier.width(animatedBarWidth) else Modifier.fillMaxWidth())
.shadow(
elevation = 16.dp,
shape = RoundedCornerShape(24.dp),
spotColor = MaterialTheme.colorScheme.primary,
ambientColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)
)
) {
Box(Modifier.fillMaxWidth().height(80.dp)) {
var barWidthPx by remember { mutableStateOf(0f) }
val itemCount = selectedRoutes.size.coerceAtLeast(1)
val density = androidx.compose.ui.platform.LocalDensity.current
val selectedIndex = remember(currentRoute, selectedRoutes) {
selectedRoutes.indexOf(currentRoute).coerceAtLeast(0)
}
)
val itemWidthPx = remember(barWidthPx, itemCount) { if (itemCount > 0) barWidthPx / itemCount else 0f }
val offsetAnim = remember { Animatable(0f) }
var lastSelectedIndex by remember { mutableStateOf(selectedIndex) }
LaunchedEffect(itemWidthPx) {
if (itemWidthPx > 0f) {
offsetAnim.snapTo(selectedIndex * itemWidthPx)
}
}
LaunchedEffect(selectedIndex, itemWidthPx) {
if (itemWidthPx <= 0f) return@LaunchedEffect
val dist = kotlin.math.abs(selectedIndex - lastSelectedIndex).coerceAtLeast(1)
val damping = when {
dist >= 3 -> 0.65f
dist == 2 -> 0.75f
else -> 0.90f
}
val stiffness = Spring.StiffnessMediumLow
offsetAnim.animateTo(
targetValue = selectedIndex * itemWidthPx,
animationSpec = spring(dampingRatio = damping, stiffness = stiffness)
)
lastSelectedIndex = selectedIndex
}
val horizontalInset = 8.dp
val indicatorWidth = with(density) { itemWidthPx.toDp() } - horizontalInset * 2
Box(
modifier = Modifier
.fillMaxSize()
.onGloballyPositioned { barWidthPx = it.size.width.toFloat() }
) {
val motionProgress = remember { Animatable(1f) }
LaunchedEffect(selectedIndex) {
motionProgress.snapTo(0f)
val dist = kotlin.math.abs(selectedIndex - lastSelectedIndex).coerceAtLeast(1)
val dur = when {
dist >= 3 -> 440
dist == 2 -> 380
else -> 320
}
motionProgress.animateTo(1f, animationSpec = tween(durationMillis = dur, easing = FastOutSlowInEasing))
}
val pulse = sin(PI * motionProgress.value).toFloat()
val distForScale = kotlin.math.abs(selectedIndex - lastSelectedIndex).coerceAtLeast(1)
val scaleXBase = 0.18f
val scaleXExtra = 0.06f
val scaleYBase = 0.06f
val scaleYExtra = 0.02f
val mult = (distForScale - 1).coerceAtLeast(0)
val scaleXAnim = 1f + (scaleXBase + scaleXExtra * mult) * pulse
val scaleYAnim = 1f - (scaleYBase + scaleYExtra * mult) * pulse
if (barWidthPx > 0f && itemCount > 0) {
val offsetX = with(density) { offsetAnim.value.toDp() } + horizontalInset
Box(
modifier = Modifier
.fillMaxHeight()
.width(indicatorWidth.coerceAtLeast(0.dp))
.offset(x = offsetX)
.padding(vertical = 8.dp)
.graphicsLayer { scaleX = scaleXAnim; scaleY = scaleYAnim }
.clip(RoundedCornerShape(14.dp))
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.10f))
.border(
BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.30f)),
RoundedCornerShape(14.dp)
)
)
}
}
NavigationBar(
containerColor = Color.Transparent,
tonalElevation = 0.dp,
modifier = Modifier.fillMaxSize()
) {
selectedRoutes.forEach { route ->
NavigationBarItem(
alwaysShowLabel = true,
icon = { Icon(imageVector = route.routeInfo.icon, contentDescription = null) },
label = {
val label = if (route.routeInfo.id == "friend_tracker") "Tracker" else context.translation["manager.routes.${route.routeInfo.key.substringBefore("/")}"]
val isLong = label.length > 11
Text(
text = label,
textAlign = TextAlign.Center,
fontSize = 12.sp,
maxLines = if (isLong) 2 else 1,
overflow = if (isLong) TextOverflow.Ellipsis else TextOverflow.Clip,
softWrap = isLong,
modifier = if (isLong) Modifier.widthIn(max = 80.dp).wrapContentWidth(Alignment.CenterHorizontally) else Modifier.wrapContentWidth(Alignment.CenterHorizontally)
)
},
selected = currentRoute == route,
colors = NavigationBarItemDefaults.colors(indicatorColor = Color.Transparent),
onClick = { route.navigateReset() }
)
}
}
}
}
if (openBottomBarCustomization) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(onDismissRequest = { openBottomBarCustomization = false }, sheetState = sheetState) {
Column(Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(112.dp)
.clip(RoundedCornerShape(bottomStart = 24.dp, bottomEnd = 24.dp))
.background(
brush = Brush.linearGradient(
colors = listOf(
MaterialTheme.colorScheme.primary.copy(alpha = 0.16f),
MaterialTheme.colorScheme.tertiary.copy(alpha = 0.2f)
)
)
)
.padding(horizontal = 20.dp, vertical = 16.dp)
) {
Column(Modifier.fillMaxSize()) {
Text(text = "Customize Bottom Bar", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface)
Spacer(Modifier.height(4.dp))
Text(text = "Reorder, add or remove tabs. Max of five.", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 8.dp)
.width(36.dp)
.height(4.dp)
.clip(RoundedCornerShape(50))
.background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f))
)
}
Spacer(Modifier.height(8.dp))
Text(text = "Shown Tabs", style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(horizontal = 16.dp))
Spacer(Modifier.height(8.dp))
if (selectedTabIds.isEmpty()) {
Text(text = "No tabs selected", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
val haptic = LocalHapticFeedback.current
var draggingId by remember { mutableStateOf<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 = if (route.routeInfo.id == "friend_tracker") "Tracker" else context.translation["manager.routes.${route.routeInfo.key.substringBefore("/")}"]
val isDragging = draggingId == id
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 6.dp)
.animateItem()
.zIndex(if (isDragging) 1f else 0f)
.graphicsLayer { if (isDragging) { scaleX = 1.02f; scaleY = 1.02f } }
.onGloballyPositioned { if (rowHeight == 0) rowHeight = it.size.height }
.pointerInput(id) {
detectDragGestures(
onDragStart = {
draggingId = id
dragStartIndex = selectedTabIds.indexOf(id)
dragDelta = 0f
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
},
onDrag = { _: PointerInputChange, dragAmount ->
dragDelta += dragAmount.y
if (rowHeight > 0 && dragStartIndex >= 0) {
val currentIndex = selectedTabIds.indexOf(id)
val deltaRows = round(dragDelta / rowHeight.toFloat()).toInt()
val targetIndex = (dragStartIndex + deltaRows).coerceIn(0, selectedTabIds.lastIndex)
if (targetIndex != currentIndex) {
val list = selectedTabIds.toMutableList()
list.removeAt(currentIndex)
list.add(targetIndex, id)
selectedTabIds = list
saveSelected(selectedTabIds)
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
}
}
},
onDragEnd = { draggingId = null; dragDelta = 0f; dragStartIndex = -1 },
onDragCancel = { draggingId = null; dragDelta = 0f; dragStartIndex = -1 }
)
}
) {
Row(
modifier = Modifier
.padding(10.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Filled.DragHandle, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.width(8.dp))
Icon(route.routeInfo.icon, contentDescription = null)
Spacer(Modifier.width(12.dp))
Text(text = label, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis)
val defaultEligible = remember { setOf("tasks","features","home","social","scripts") }
RadioButton(selected = defaultTabId == id, onClick = { if (id in defaultEligible) saveDefault(id) }, enabled = id in defaultEligible)
IconButton(
onClick = {
if (selectedTabIds.size > 1 && id != defaultTabId && id != "home") {
selectedTabIds = selectedTabIds.toMutableList().also { it.removeAt(index) }
saveSelected(selectedTabIds)
}
},
enabled = id != defaultTabId && id != "home"
) { Icon(Icons.Filled.Close, contentDescription = null) }
}
}
}
}
}
Spacer(Modifier.height(16.dp))
Text(text = "Available Tabs", style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(horizontal = 16.dp))
Spacer(Modifier.height(8.dp))
FlowRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
availableRoutes.forEach { route ->
val id = route.routeInfo.id
val already = selectedTabIds.contains(id)
val label = if (id == "friend_tracker") "Tracker" else context.translation["manager.routes.${route.routeInfo.key.substringBefore("/")}"]
AnimatedVisibility(
visible = !already && selectedTabIds.size < 5,
enter = scaleIn(tween(160), initialScale = 0.95f) + fadeIn(tween(180)) + slideInVertically(tween(180), initialOffsetY = { it / 3 }),
exit = scaleOut(tween(120)) + fadeOut(tween(120)) + slideOutVertically(tween(120))
) {
AssistChip(
onClick = {
if (!already && selectedTabIds.size < 5) {
selectedTabIds = selectedTabIds + id
saveSelected(selectedTabIds)
}
},
label = { Text(text = label) },
leadingIcon = { Icon(route.routeInfo.icon, contentDescription = null) },
enabled = true
)
}
}
}
Spacer(Modifier.height(20.dp))
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
OutlinedButton(onClick = {
selectedTabIds = defaultOrder
saveSelected(selectedTabIds)
}) { Text(text = "Reset", style = MaterialTheme.typography.labelLarge) }
Button(onClick = { openBottomBarCustomization = false }) { Text(text = "Done", style = MaterialTheme.typography.labelLarge) }
}
Spacer(Modifier.height(8.dp))
}
}
}
}
}
@Composable
fun FloatingActionButton() {
fun Fab() {
val navBackStackEntry by navController.currentBackStackEntryAsState()
remember(navBackStackEntry) { routes.getCurrentRoute(navBackStackEntry) }?.floatingActionButton?.invoke()
}
@Composable
fun Content(paddingValues: PaddingValues, startDestination: String) {
fun NavContent(paddingValues: PaddingValues, startDestination: String) {
NavHost(
navController = navController,
startDestination = startDestination,
Modifier.padding(paddingValues),
enterTransition = { fadeIn(tween(100)) },
exitTransition = { fadeOut(tween(100)) }
modifier = Modifier.padding(paddingValues)
) {
routes.getRoutes().filter { it.parentRoute == null }.forEach { route ->
val children = routes.getRoutes().filter { it.parentRoute == route }
if (children.isEmpty()) {
composable(route.routeInfo.id) {
route.content.invoke(it)
}
val isSummaryScreen = route.routeInfo.id == Routes.CONFIG_IMPORT_CONFIRMATION_ROUTE ||
route.routeInfo.id == Routes.CONFIG_EXPORT_SUMMARY_ROUTE ||
route.routeInfo.id == Routes.FRIEND_TRACKER_CONFIG_EXPORT_ROUTE ||
route.routeInfo.id == Routes.FRIEND_TRACKER_CONFIG_IMPORT_ROUTE
val isAddRuleScreen = route.routeInfo.id.startsWith("edit_rule")
val animatedRoutes = setOf("friend_tracker_catalog", "manage_friend_tracker_repos", "manage_script_repos", "manage_repos")
val isAnimatedRoute = animatedRoutes.contains(route.routeInfo.id)
val addRuleEnterAnimation = slideInHorizontally(animationSpec = tween(400)) { it }
val addRuleExitAnimation = slideOutHorizontally(animationSpec = tween(400)) { -it }
val addRulePopEnterAnimation = slideInHorizontally(animationSpec = tween(400)) { -it }
val addRulePopExitAnimation = slideOutHorizontally(animationSpec = tween(400)) { it }
val animatedRouteEnter = slideInHorizontally(animationSpec = tween(400)) { it }
val animatedRouteExit = slideOutHorizontally(animationSpec = tween(400)) { -it }
val animatedRoutePopEnter = slideInHorizontally(animationSpec = tween(400)) { -it }
val animatedRoutePopExit = slideOutHorizontally(animationSpec = tween(400)) { it }
composable(
route.routeInfo.id,
enterTransition = {
when {
isSummaryScreen -> slideInHorizontally { it }
isAddRuleScreen -> addRuleEnterAnimation
isAnimatedRoute -> animatedRouteEnter
else -> fadeIn(tween(100))
}
},
exitTransition = {
when {
isSummaryScreen -> slideOutHorizontally { -it }
isAddRuleScreen -> addRuleExitAnimation
isAnimatedRoute -> animatedRouteExit
else -> fadeOut(tween(100))
}
},
popEnterTransition = {
when {
isSummaryScreen -> slideInHorizontally { -it }
isAddRuleScreen -> addRulePopEnterAnimation
isAnimatedRoute -> animatedRoutePopEnter
else -> fadeIn(tween(100))
}
},
popExitTransition = {
when {
isSummaryScreen -> slideOutHorizontally { it }
isAddRuleScreen -> addRulePopExitAnimation
isAnimatedRoute -> animatedRoutePopExit
else -> fadeOut(tween(100))
}
}
) { route.content.invoke(it) }
route.customComposables.invoke(this)
} else {
navigation("main_" + route.routeInfo.id, route.routeInfo.id) {
composable("main_" + route.routeInfo.id) {
route.content.invoke(it)
}
children.forEach { child ->
composable(child.routeInfo.id) {
child.content.invoke(it)
}
}
composable("main_" + route.routeInfo.id) { route.content.invoke(it) }
children.forEach { child -> composable(child.routeInfo.id) { child.content.invoke(it) } }
route.customComposables.invoke(this)
}
}
}
}
}
@Composable fun FloatingActionButton() = Fab()
@Composable fun Content(paddingValues: PaddingValues, startDestination: String) = NavContent(paddingValues, startDestination)
}

View File

@@ -5,6 +5,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
import androidx.navigation.NavDestination.Companion.hierarchy
@@ -28,6 +29,9 @@ import me.rhunk.snapenhance.ui.manager.pages.social.MessagingPreview
import me.rhunk.snapenhance.ui.manager.pages.social.SocialRootSection
import me.rhunk.snapenhance.ui.manager.pages.tracker.EditRule
import me.rhunk.snapenhance.ui.manager.pages.tracker.FriendTrackerManagerRoot
import me.rhunk.snapenhance.ui.manager.pages.tracker.FriendTrackerCatalog
import me.rhunk.snapenhance.ui.manager.pages.tracker.ManageFriendTrackerReposSection
import me.rhunk.snapenhance.ui.manager.pages.scripting.ManageScriptReposSection
data class RouteInfo(
@@ -36,6 +40,7 @@ data class RouteInfo(
val icon: ImageVector = Icons.Default.Home,
val primary: Boolean = false,
val showInNavBar: Boolean = primary,
val hasOwnTopBar: Boolean = false,
) {
var translatedKey: Lazy<String?>? = null
val childIds = mutableListOf<String>()
@@ -45,8 +50,23 @@ data class RouteInfo(
class Routes(
private val context: RemoteSideContext,
) {
companion object {
const val CONFIG_IMPORT_CONFIRMATION_ROUTE = "config_import_confirmation"
const val CONFIG_EXPORT_SUMMARY_ROUTE = "config_export_summary/?exportSensitiveData={exportSensitiveData}"
const val FRIEND_TRACKER_CONFIG_EXPORT_ROUTE = "friend_tracker_config_export/?rule_id={rule_id}"
const val FRIEND_TRACKER_CONFIG_IMPORT_ROUTE = "friend_tracker_config_import"
}
lateinit var navController: NavController
lateinit var activityLauncher: me.rhunk.snapenhance.ui.util.ActivityLauncherHelper
private val routes = mutableListOf<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 tasks = route(RouteInfo("tasks", icon = Icons.Default.TaskAlt, primary = true), TasksRootSection())
@@ -57,11 +77,15 @@ class Routes(
val settings = route(RouteInfo("home_settings"), HomeSettings()).parent(home)
val homeLogs = route(RouteInfo("home_logs"), HomeLogs()).parent(home)
val loggerHistory = route(RouteInfo("logger_history"), LoggerHistoryRoot()).parent(home)
val friendTracker = route(RouteInfo("friend_tracker"), FriendTrackerManagerRoot()).parent(home)
val editRule = route(RouteInfo("edit_rule/?rule_id={rule_id}"), EditRule())
val friendTracker = route(RouteInfo("friend_tracker", icon = Icons.Default.PersonSearch), FriendTrackerManagerRoot()).parent(home)
val editRule = route(RouteInfo("edit_rule/?rule_id={rule_id}", hasOwnTopBar = true), EditRule())
val friendTrackerConfigExport = route(RouteInfo(FRIEND_TRACKER_CONFIG_EXPORT_ROUTE), me.rhunk.snapenhance.ui.manager.pages.tracker.FriendTrackerConfigExportScreen())
val friendTrackerConfigImport = route(RouteInfo(FRIEND_TRACKER_CONFIG_IMPORT_ROUTE), me.rhunk.snapenhance.ui.manager.pages.tracker.FriendTrackerConfigImportScreen())
val friendTrackerCatalog = route(RouteInfo("friend_tracker_catalog"), FriendTrackerCatalog())
val manageFriendTrackerRepos = route(RouteInfo("manage_friend_tracker_repos"), ManageFriendTrackerReposSection())
val fileImports = route(RouteInfo("file_imports"), FileImportsRoot()).parent(home)
val manageRepos = route(RouteInfo("manage_repos"), ManageReposSection())
val manageRepos = route(RouteInfo("manage_repos/?type={type}"), ManageReposSection())
val social = route(RouteInfo("social", icon = Icons.Default.Group, primary = true), SocialRootSection())
val manageScope = route(RouteInfo("manage_scope/?scope={scope}&id={id}"), ManageScope()).parent(social)
@@ -69,6 +93,7 @@ class Routes(
val loggedStories = route(RouteInfo("logged_stories/?id={id}"), LoggedStories()).parent(social)
val scripting = route(RouteInfo("scripts", icon = Icons.Filled.DataObject, primary = true), ScriptingRootSection())
val manageScriptRepos = route(RouteInfo("manage_script_repos"), ManageScriptReposSection())
val betterLocation = route(RouteInfo("better_location", showInNavBar = false, primary = true), BetterLocationRoot())
@@ -152,4 +177,4 @@ class Routes(
routes.add(route)
return route
}
}
}

View File

@@ -0,0 +1,83 @@
package me.rhunk.snapenhance.ui.manager.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import me.rhunk.snapenhance.ui.util.Motion
@Composable
fun AestheticDialog(
onDismissRequest: () -> Unit,
title: String,
text: String,
icon: ImageVector,
confirmButtonText: String,
onConfirm: () -> Unit,
dismissButtonText: String? = null,
onDismiss: (() -> Unit)? = null
) {
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { visible = true }
Dialog(onDismissRequest = onDismissRequest) {
AnimatedVisibility(
visible = visible,
enter = fadeIn(animationSpec = Motion.tweenFloatSpec(180)) + scaleIn(animationSpec = Motion.tweenFloatSpec(220)),
exit = fadeOut(animationSpec = Motion.tweenFloatSpec(150)) + scaleOut(animationSpec = Motion.tweenFloatSpec(180))
) {
Card(
shape = RoundedCornerShape(16.dp),
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally)
) {
if (dismissButtonText != null && onDismiss != null) {
Button(onClick = onDismiss) {
Text(dismissButtonText)
}
}
Button(onClick = onConfirm) {
Text(confirmButtonText)
}
}
}
}
}
}
}

View File

@@ -0,0 +1,134 @@
package me.rhunk.snapenhance.ui.manager.data
import android.content.Context
import android.content.Intent
import android.widget.Toast
import androidx.core.content.FileProvider
import com.tonyodev.fetch2.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import me.rhunk.snapenhance.RemoteSideContext
import java.io.File
import java.io.FileOutputStream
import java.util.zip.ZipInputStream
object UpdateDownloader {
enum class DownloadState {
IDLE,
DOWNLOADING,
COMPLETED,
FAILED
}
val downloadState = MutableStateFlow(DownloadState.IDLE)
val downloadProgress = MutableStateFlow(0f)
private var fetch: Fetch? = null
private var listener: FetchListener? = null
private fun getInstance(context: Context): Fetch {
val appContext = context.applicationContext
// If RemoteSideContext is part of your app, keep this, otherwise remove
fetch?.let { return it }
fetch = when (appContext) {
is RemoteSideContext -> appContext.fetch
else -> {
val fetchConfiguration = FetchConfiguration.Builder(appContext)
.setDownloadConcurrentLimit(3)
.build()
Fetch.getInstance(fetchConfiguration)
}
}
return fetch!!
}
private fun unzip(zipFile: File, targetDirectory: File) {
ZipInputStream(zipFile.inputStream()).use { zis ->
var zipEntry = zis.nextEntry
while (zipEntry != null) {
val newFile = File(targetDirectory, zipEntry.name)
if (zipEntry.isDirectory) {
newFile.mkdirs()
} else {
FileOutputStream(newFile).use { fos ->
zis.copyTo(fos)
}
}
zipEntry = zis.nextEntry
}
}
}
fun downloadAndInstall(
context: Context,
downloadUrl: String,
fileName: String,
scope: CoroutineScope
) {
val fetch = getInstance(context)
val filePath = File(context.externalCacheDir, fileName).path
val request = Request(downloadUrl, filePath).apply {
priority = Priority.HIGH
networkType = NetworkType.ALL
}
listener?.let { fetch.removeListener(it) }
listener = object : AbstractFetchListener() {
override fun onAdded(download: Download) {
downloadState.value = DownloadState.DOWNLOADING
}
override fun onQueued(download: Download, waitingOnNetwork: Boolean) {
downloadState.value = DownloadState.DOWNLOADING
Toast.makeText(context, "Download started", Toast.LENGTH_SHORT).show()
}
override fun onProgress(download: Download, etaInMilliSeconds: Long, downloadedBytesPerSecond: Long) {
downloadProgress.value = download.progress / 100f
}
override fun onCompleted(download: Download) {
downloadState.value = DownloadState.COMPLETED
Toast.makeText(context, "Download completed", Toast.LENGTH_SHORT).show()
runCatching {
val downloadedFile = File(download.file)
val unzipDir = File(context.externalCacheDir, "update")
if (unzipDir.exists()) {
unzipDir.deleteRecursively()
}
unzipDir.mkdirs()
unzip(downloadedFile, unzipDir)
val apkFile = unzipDir.walk().find { it.isFile && it.extension == "apk" }
?: throw Exception("No APK found in the downloaded file")
val uri = FileProvider.getUriForFile(context, "me.rhunk.snapenhance.fileprovider", apkFile)
val installIntent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(installIntent)
}.onFailure {
it.printStackTrace()
Toast.makeText(context, "Failed to install update. Check logs for more details.", Toast.LENGTH_SHORT).show()
downloadState.value = DownloadState.FAILED
}
fetch.removeListener(this)
scope.launch {
delay(2000)
downloadState.value = DownloadState.IDLE
}
}
override fun onError(download: Download, error: Error, throwable: Throwable?) {
downloadState.value = DownloadState.FAILED
Toast.makeText(context, "Download failed: $error", Toast.LENGTH_SHORT).show()
fetch.removeListener(this)
scope.launch {
delay(2000)
downloadState.value = DownloadState.IDLE
}
}
}
fetch.addListener(listener!!)
fetch.enqueue(request, { }, { })
}
}

View File

@@ -10,16 +10,17 @@ import okhttp3.Request
object Updater {
data class LatestRelease(
val versionName: String,
val releaseUrl: String
val releaseUrl: String,
val workflowId: Long?,
)
private fun fetchLatestRelease() = runCatching {
val endpoint = Request.Builder().url("https://api.github.com/repos/rhunk/SnapEnhance/releases").build()
val endpoint = Request.Builder().url("https://api.github.com/repos/particle-box/PurrfectSnap/releases").build()
val response = OkHttpClient().newCall(endpoint).execute()
if (!response.isSuccessful) throw Throwable("Failed to fetch releases: ${response.code}")
val releases = JsonParser.parseString(response.body.string()).asJsonArray.also {
val releases = JsonParser.parseString(response.body?.string()).asJsonArray.also {
if (it.size() == 0) throw Throwable("No releases found")
}
@@ -29,16 +30,17 @@ object Updater {
LatestRelease(
versionName = latestVersion,
releaseUrl = endpoint.url.toString().replace("api.", "").replace("repos/", "")
releaseUrl = endpoint.url.toString().replace("api.", "").replace("repos/", ""),
workflowId = null
)
}.onFailure {
AbstractLogger.directError("Failed to fetch latest release", it)
}.getOrNull()
private fun fetchLatestDebugCI() = runCatching {
val actionRuns = OkHttpClient().newCall(Request.Builder().url("https://api.github.com/repos/rhunk/SnapEnhance/actions/runs?event=workflow_dispatch").build()).execute().use {
val actionRuns = OkHttpClient().newCall(Request.Builder().url("https://api.github.com/repos/particle-box/PurrfectSnap/actions/runs?event=workflow_dispatch&branch=dev").build()).execute().use {
if (!it.isSuccessful) throw Throwable("Failed to fetch CI runs: ${it.code}")
JsonParser.parseString(it.body.string()).asJsonObject
JsonParser.parseString(it.body?.string()).asJsonObject
}
val debugRuns = actionRuns.getAsJsonArray("workflow_runs")?.mapNotNull { it.asJsonObject }?.filter { run ->
run.get("conclusion")?.takeIf { it.isJsonPrimitive }?.asString == "success" && run.getAsJsonPrimitive("path")?.asString == ".github/workflows/debug.yml"
@@ -51,7 +53,8 @@ object Updater {
LatestRelease(
versionName = headSha.substring(0, headSha.length.coerceAtMost(7)) + "-debug",
releaseUrl = latestRun.getAsJsonPrimitive("html_url")?.asString?.replace("github.com", "nightly.link") ?: return@runCatching null
releaseUrl = latestRun.getAsJsonPrimitive("html_url")?.asString ?: return@runCatching null,
workflowId = latestRun.getAsJsonPrimitive("id")?.asLong,
)
}.onFailure {
AbstractLogger.directError("Failed to fetch latest debug CI", it)
@@ -60,4 +63,4 @@ object Updater {
val latestRelease by lazy {
if (BuildConfig.DEBUG) fetchLatestDebugCI() else fetchLatestRelease()
}
}
}

View File

@@ -92,7 +92,8 @@ class FileImportsRoot: Routes.Route() {
modifier = Modifier
.fillMaxSize()
.padding(2.dp),
verticalArrangement = Arrangement.spacedBy(5.dp)
verticalArrangement = Arrangement.spacedBy(5.dp),
contentPadding = PaddingValues(bottom = routes.bottomPadding)
) {
item {
if (files.isEmpty()) {
@@ -150,9 +151,6 @@ class FileImportsRoot: Routes.Route() {
}
}
}
item {
Spacer(modifier = Modifier.height(100.dp))
}
}
}
}

View File

@@ -306,7 +306,9 @@ class LoggerHistoryRoot : Routes.Route() {
var lastFetchMessageTimestamp by remember(selectedConversation, stringFilter, reverseOrder) { mutableLongStateOf(if (reverseOrder) Long.MAX_VALUE else Long.MIN_VALUE) }
val messages = remember(selectedConversation, stringFilter, reverseOrder) { mutableStateListOf<LoggedMessage>() }
LazyColumn {
LazyColumn(
contentPadding = PaddingValues(bottom = routes.bottomPadding)
) {
items(messages) { message ->
MessageView(message)
}

View File

@@ -1,8 +1,10 @@
@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
package me.rhunk.snapenhance.ui.manager.pages
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.animation.animateContentSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Public
import androidx.compose.material3.*
@@ -18,6 +20,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import androidx.navigation.NavBackStackEntry
import androidx.navigation.compose.currentBackStackEntryAsState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import me.rhunk.snapenhance.common.data.RepositoryIndex
@@ -37,6 +40,9 @@ class ManageReposSection: Routes.Route() {
override val floatingActionButton: @Composable () -> Unit = {
var showAddDialog by remember { mutableStateOf(false) }
val navBackStackEntry by routes.navController.currentBackStackEntryAsState()
val repoType = navBackStackEntry?.arguments?.getString("type") ?: "theme"
ExtendedFloatingActionButton(onClick = {
showAddDialog = true
}) {
@@ -59,7 +65,7 @@ class ManageReposSection: Routes.Route() {
if (!response.isSuccessful) {
throw Exception("Failed to fetch default branch: ${response.code}")
}
val json = response.body.string()
val json = response.body?.string()
val defaultBranch = context.gson.fromJson(json, Map::class.java)["default_branch"] as String
context.log.info("Default branch for $repoName is $defaultBranch")
modifiedUrl = "https://raw.githubusercontent.com/$repoName/$defaultBranch/"
@@ -74,11 +80,11 @@ class ManageReposSection: Routes.Route() {
throw Exception("Failed to fetch index from $indexUri: ${response.code}")
}
runCatching {
val repoIndex = context.gson.fromJson(response.body.charStream(), RepositoryIndex::class.java).also {
val repoIndex = context.gson.fromJson(response.body?.charStream(), RepositoryIndex::class.java).also {
context.log.info("repository index: $it")
}
context.database.addRepo(modifiedUrl)
context.database.addRepo(repoType, modifiedUrl)
context.shortToast("Repository added successfully! $repoIndex")
showAddDialog = false
updateDispatcher.dispatch()
@@ -144,13 +150,14 @@ class ManageReposSection: Routes.Route() {
override val content: @Composable (NavBackStackEntry) -> Unit = {
val coroutineScope = rememberCoroutineScope()
val repoType = it.arguments?.getString("type") ?: "theme"
val repositories = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = updateDispatcher) {
context.database.getRepositories()
context.database.getRepositories(repoType)
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(8.dp),
contentPadding = PaddingValues(start = 8.dp, top = 8.dp, end = 8.dp, bottom = 8.dp + routes.bottomPadding),
) {
item {
if (repositories.isEmpty()) {
@@ -162,7 +169,7 @@ class ManageReposSection: Routes.Route() {
items(repositories) { url ->
ElevatedCard(onClick = {
context.androidContext.copyToClipboard(url)
}) {
}, modifier = Modifier.animateContentSize()) {
Row(
modifier = Modifier
.fillMaxWidth()
@@ -174,7 +181,7 @@ class ManageReposSection: Routes.Route() {
Text(text = url, modifier = Modifier.weight(1f), overflow = TextOverflow.Ellipsis, maxLines = 4, fontSize = 15.sp, lineHeight = 15.sp)
Button(
onClick = {
context.database.removeRepo(url)
context.database.removeRepo(repoType, url)
coroutineScope.launch {
updateDispatcher.dispatch()
}
@@ -187,4 +194,4 @@ class ManageReposSection: Routes.Route() {
}
}
}
}
}

View File

@@ -483,7 +483,8 @@ class TasksRootSection : Routes.Route() {
LazyColumn(
state = scrollState,
modifier = Modifier.fillMaxSize()
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = routes.bottomPadding)
) {
item {
if (activeTasks.isEmpty() && recentTasks.isEmpty()) {

View File

@@ -0,0 +1,243 @@
package me.rhunk.snapenhance.ui.manager.pages.features
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import me.rhunk.snapenhance.ui.manager.Routes
import me.rhunk.snapenhance.ui.util.saveFile
import org.json.JSONArray
import org.json.JSONObject
class ConfigExportSummaryScreen : Routes.Route() {
private data class ImportedFeature(
val category: String,
val name: String,
val key: String,
val value: Any,
val indentation: Int
)
private inner class ConfigParser {
fun parse(configJson: String): Map<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, "Enable Feature", categoryKey, value.getBoolean("state"), 0))
} else if (value.has("properties")) {
parseProperties(categoryKey, niceCategoryName, value.getJSONObject("properties"), "", 0)
}
}
}
return featureList.groupBy { it.category }
}
fun parseValue(featureKey: String, value: Any): Any {
fun innerParse(v: Any): String {
if (v is String) {
val translationKey = "features.options.$featureKey.$v"
val translated = context.translation[translationKey]
return translated!!
}
return v.toString()
}
return when (value) {
is Boolean -> if (value) "Enabled" else "Disabled"
is JSONArray -> {
val list = mutableListOf<String>()
for (i in 0 until value.length()) {
list.add(innerParse(value.get(i)))
}
list
}
else -> innerParse(value)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
val exportSensitiveData = it.arguments?.getBoolean("exportSensitiveData") ?: false
val parser = remember { ConfigParser() }
val featuresByCategory = remember {
parser.parse(context.config.exportToString(exportSensitiveData))
}
val expandedState = remember { mutableStateMapOf<String, Boolean>() }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Export Summary") },
navigationIcon = {
IconButton(onClick = { routes.navController.popBackStack() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
TextButton(onClick = {
routes.activityLauncher.saveFile("config.json", "application/json") { uri ->
runCatching {
context.androidContext.contentResolver.openOutputStream(android.net.Uri.parse(uri))?.use {
context.config.writeConfig()
context.config.exportToString(exportSensitiveData).byteInputStream().copyTo(it)
context.shortToast(context.translation["manager.sections.features.config_export_success_toast"])
}
}.onFailure {
context.longToast(
context.translation.format(
"manager.sections.features.config_export_failure_toast",
"error" to it.message.toString()
)
)
}
}
}) {
Text("Save")
}
}
)
}
) { padding ->
LazyColumn(
modifier = Modifier
.padding(padding)
.fillMaxSize(),
contentPadding = PaddingValues(
start = 16.dp,
top = 16.dp,
end = 16.dp,
bottom = 16.dp + routes.bottomPadding
),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(featuresByCategory.toList()) { (category, features) ->
val isExpanded = expandedState[category] ?: false
val rotationState by animateFloatAsState(targetValue = if (isExpanded) 180f else 0f)
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { expandedState[category] = !isExpanded },
shape = RoundedCornerShape(16.dp)
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = category,
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
modifier = Modifier.weight(1f)
)
IconButton(onClick = { expandedState[category] = !isExpanded }) {
Icon(
imageVector = Icons.Default.KeyboardArrowDown,
contentDescription = "Expand",
modifier = Modifier.graphicsLayer(rotationZ = rotationState)
)
}
}
AnimatedVisibility(visible = isExpanded) {
Column {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
features.forEachIndexed { index, feature ->
when (val parsedValue = parser.parseValue(feature.key, feature.value)) {
is List<*> -> {
Column(modifier = Modifier.padding(start = (feature.indentation * 16).dp, top = 4.dp, bottom = 4.dp)) {
Text(
text = feature.name,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(4.dp))
Column(modifier = Modifier.padding(start = 16.dp)) {
parsedValue.forEach { item ->
Row {
Text(
text = "",
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(end = 8.dp)
)
Text(
text = item.toString(),
color = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
is String -> {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.padding(start = (feature.indentation * 16).dp),
verticalAlignment = Alignment.Top
) {
Text(
text = feature.name,
modifier = Modifier.weight(1f),
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = parsedValue,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.End,
)
}
}
}
if (index < features.size - 1) {
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
}
}
}
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,330 @@
package me.rhunk.snapenhance.ui.manager.pages.features
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import me.rhunk.snapenhance.ui.manager.Routes
import org.json.JSONArray
import org.json.JSONObject
class ConfigImportConfirmationScreen : Routes.Route() {
private data class ImportedFeature(
val category: String,
val name: String,
val key: String,
val value: Any,
val indentation: Int
)
private inner class ConfigParser {
fun parse(configJson: String): Map<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,
"Enable Feature",
categoryKey,
value.getBoolean("state"),
0
)
)
} else if (value.has("properties")) {
parseProperties(
categoryKey,
niceCategoryName,
value.getJSONObject("properties"),
"",
0
)
}
}
}
return featureList.groupBy { it.category }
}
fun parseValue(featureKey: String, value: Any): Any {
fun innerParse(v: Any): String {
if (v is String) {
val translationKey = "features.options.$featureKey.$v"
val translated = context.translation[translationKey]
return translated!!
}
return v.toString()
}
return when (value) {
is Boolean -> if (value) "Enabled" else "Disabled"
is JSONArray -> {
val list = mutableListOf<String>()
for (i in 0 until value.length()) {
list.add(innerParse(value.get(i)))
}
list
}
else -> innerParse(value)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
val parser = remember { ConfigParser() }
val featuresByCategory = remember {
routes.configJsonForImport?.let { parser.parse(it) } ?: emptyMap()
}
val expandedState = remember { mutableStateMapOf<String, Boolean>() }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Confirm Import") },
navigationIcon = {
IconButton(onClick = { routes.navController.popBackStack() }) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
},
actions = {
TextButton(onClick = {
routes.configJsonForImport?.let {
runCatching {
context.config.loadFromString(it)
}.onFailure { err ->
context.longToast(
context.translation.format(
"config_import_failure_toast",
"error" to (err.message ?: "Unknown error")
)
)
// Return is not needed since last statement in lambda
}
context.shortToast("Config Imported!")
context.coroutineScope.launch(Dispatchers.Main) {
routes.features.navigateReload()
}
}
}) {
Text("Confirm")
}
}
)
}
) { padding ->
LazyColumn(
modifier = Modifier
.padding(padding)
.fillMaxSize(),
contentPadding = PaddingValues(
start = 16.dp,
top = 16.dp,
end = 16.dp,
bottom = 16.dp + routes.bottomPadding
),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(featuresByCategory.toList()) { (category, features) ->
val isExpanded = expandedState[category] ?: false
val rotationState by animateFloatAsState(targetValue = if (isExpanded) 180f else 0f)
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { expandedState[category] = !isExpanded },
shape = RoundedCornerShape(16.dp)
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = category,
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
modifier = Modifier.weight(1f)
)
IconButton(onClick = { expandedState[category] = !isExpanded }) {
Icon(
imageVector = Icons.Default.KeyboardArrowDown,
contentDescription = "Expand",
modifier = Modifier.graphicsLayer(rotationZ = rotationState)
)
}
}
AnimatedVisibility(visible = isExpanded) {
Column {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
features.forEachIndexed { index, feature ->
when (val parsedValue = parser.parseValue(feature.key, feature.value)) {
is List<*> -> {
Column(
modifier = Modifier
.padding(
start = (feature.indentation * 16).dp,
top = 4.dp,
bottom = 4.dp
)
) {
Text(
text = feature.name,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(4.dp))
Column(modifier = Modifier.padding(start = 16.dp)) {
parsedValue.forEach { item ->
Row {
Text(
text = "",
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(end = 8.dp)
)
Text(
text = item.toString(),
color = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
is String -> {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.padding(start = (feature.indentation * 16).dp),
verticalAlignment = Alignment.Top
) {
Text(
text = feature.name,
modifier = Modifier.weight(1f),
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = parsedValue,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.End,
)
}
}
}
// Always show divider only when there are more features to follow
if (index < features.size - 1) {
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
}
}
}
}
}
}
}
}
}
}
}

View File

@@ -12,6 +12,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
@@ -21,11 +22,15 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.lifecycle.Lifecycle
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavGraph.Companion.findStartDestination
@@ -43,6 +48,8 @@ import me.rhunk.snapenhance.common.ui.transparentTextFieldColors
import me.rhunk.snapenhance.ui.manager.MainActivity
import me.rhunk.snapenhance.ui.manager.Routes
import me.rhunk.snapenhance.ui.util.*
import org.json.JSONArray
import org.json.JSONObject
class FeaturesRootSection : Routes.Route() {
private val alertDialogs by lazy { AlertDialogs(context.translation) }
@@ -87,18 +94,8 @@ class FeaturesRootSection : Routes.Route() {
)
}
override val init: () -> Unit = {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
}
private fun activityLauncher(block: ActivityLauncherHelper.() -> Unit) {
activityLauncherHelper?.let(block) ?: run {
//open manager if activity launcher is null
val intent = Intent(context.androidContext, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
intent.putExtra("route", routeInfo.id)
context.androidContext.startActivity(intent)
}
routes.activityLauncher.let(block)
}
override val content: @Composable (NavBackStackEntry) -> Unit = {
@@ -251,9 +248,13 @@ class FeaturesRootSection : Routes.Route() {
when (val dataType = remember { property.key.dataType.type }) {
DataProcessors.Type.BOOLEAN -> {
var state by remember { mutableStateOf(propertyValue.get() as Boolean) }
val hapticFeedback = LocalHapticFeedback.current
Switch(
checked = state,
onCheckedChange = registerClickCallback {
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
state = state.not()
propertyValue.setAny(state)
}
@@ -363,9 +364,13 @@ class FeaturesRootSection : Routes.Route() {
))
}
val hapticFeedback = LocalHapticFeedback.current
Switch(
checked = state,
onCheckedChange = {
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
state = state.not()
container.globalState = state
}
@@ -521,6 +526,41 @@ class FeaturesRootSection : Routes.Route() {
}
}
@Composable
private fun SensitiveDataDialog(
onDismiss: () -> Unit,
onConfirm: (exportSensitiveData: Boolean) -> Unit
) {
Dialog(onDismissRequest = onDismiss) {
Card(shape = RoundedCornerShape(16.dp)) {
Column(modifier = Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "Export Sensitive Data?",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(bottom = 16.dp)
)
Text(
text = "Do you want to export the config with sensitive data? (Such as location coordinates, etc.)",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 24.dp)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
) {
TextButton(onClick = { onConfirm(false) }) {
Text("No")
}
TextButton(onClick = { onConfirm(true) }) {
Text("Yes")
}
}
}
}
}
}
override val topBarActions: @Composable (RowScope.() -> Unit) = topBarActions@{
var showSearchBar by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
@@ -590,41 +630,12 @@ class FeaturesRootSection : Routes.Route() {
}
if (showExportDialog) {
fun exportConfig(
exportSensitiveData: Boolean
) {
showExportDialog = false
activityLauncher {
saveFile("config.json", "application/json") { uri ->
runCatching {
context.androidContext.contentResolver.openOutputStream(Uri.parse(uri))?.use {
context.config.writeConfig()
context.config.exportToString(exportSensitiveData).byteInputStream().copyTo(it)
context.shortToast(translation["config_export_success_toast"])
}
}.onFailure {
context.longToast(translation.format("config_export_failure_toast", "error" to it.message.toString()))
}
}
}
}
AlertDialog(
title = { Text(text = context.translation["manager.dialogs.export_config.title"]) },
text = { Text(text = context.translation["manager.dialogs.export_config.content"]) },
onDismissRequest = { showExportDialog = false },
confirmButton = {
Button(
onClick = { exportConfig(true) }
) {
Text(text = context.translation["button.positive"])
}
},
dismissButton = {
Button(
onClick = { exportConfig(false) }
) {
Text(text = context.translation["button.negative"])
SensitiveDataDialog(
onDismiss = { showExportDialog = false },
onConfirm = { exportSensitiveData ->
showExportDialog = false
routes.configExportSummary.navigate {
put("exportSensitiveData", exportSensitiveData.toString())
}
}
)
@@ -637,16 +648,8 @@ class FeaturesRootSection : Routes.Route() {
activityLauncher {
openFile("application/json") { uri ->
context.androidContext.contentResolver.openInputStream(Uri.parse(uri))?.use {
runCatching {
context.config.loadFromString(it.readBytes().toString(Charsets.UTF_8))
}.onFailure {
context.longToast(translation.format("config_import_failure_toast", "error" to it.message.toString()))
return@use
}
context.shortToast(translation["config_import_success_toast"])
context.coroutineScope.launch(Dispatchers.Main) {
navigateReload()
}
routes.configJsonForImport = it.readBytes().toString(Charsets.UTF_8)
routes.navController.navigate(Routes.CONFIG_IMPORT_CONFIRMATION_ROUTE)
}
}
}
@@ -685,23 +688,15 @@ class FeaturesRootSection : Routes.Route() {
private fun PropertiesView(
properties: List<PropertyPair<*>>
) {
Scaffold(
LazyColumn(
modifier = Modifier.fillMaxSize(),
content = { innerPadding ->
LazyColumn(
modifier = Modifier
.fillMaxHeight()
.padding(innerPadding),
//save button space
contentPadding = PaddingValues(top = 10.dp, bottom = 110.dp),
verticalArrangement = Arrangement.Top
) {
items(properties, key = { it.key.propertyName() }) {
PropertyCard(it)
}
}
verticalArrangement = Arrangement.Top,
contentPadding = PaddingValues(bottom = routes.bottomPadding)
) {
items(properties, key = { it.key.propertyName() }) {
PropertyCard(it)
}
)
}
}
override val floatingActionButton: @Composable () -> Unit = {
@@ -726,12 +721,15 @@ class FeaturesRootSection : Routes.Route() {
}
@Composable
private fun Container(
configContainer: ConfigContainer
) {
PropertiesView(remember {
configContainer.properties.map { PropertyPair(it.key, it.value) }
configContainer.properties.map { PropertyPair(it.key, it.value) }.filter {
!it.key.params.flags.contains(ConfigFlag.HIDDEN)
}
})
}
}
}

View File

@@ -1,3 +1,4 @@
@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
package me.rhunk.snapenhance.ui.manager.pages.home
import android.net.Uri
@@ -8,6 +9,8 @@ import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.animation.animateContentSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardDoubleArrowDown
import androidx.compose.material.icons.filled.KeyboardDoubleArrowUp
@@ -21,6 +24,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
@@ -45,20 +49,16 @@ import me.rhunk.snapenhance.ui.util.saveFile
class HomeLogs : Routes.Route() {
private val logListState by lazy { LazyListState(0) }
private lateinit var activityLauncherHelper: ActivityLauncherHelper
override val init: () -> Unit = {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
}
override val topBarActions: @Composable (RowScope.() -> Unit) = {
var showDropDown by remember { mutableStateOf(false) }
IconButton(onClick = {
showDropDown = true
}) {
Icon(Icons.Filled.MoreVert, contentDescription = null)
}
DropdownMenu(
expanded = showDropDown,
onDismissRequest = { showDropDown = false },
@@ -73,9 +73,8 @@ class HomeLogs : Routes.Route() {
}, text = {
Text(translation["clear_logs_button"])
})
DropdownMenuItem(onClick = {
activityLauncherHelper.saveFile("snapenhance-logs-${System.currentTimeMillis()}.zip", "application/zip") { uri ->
activityLauncherHelper.saveFile("purrfectsnap-logs-${System.currentTimeMillis()}.zip", "application/zip") { uri ->
context.coroutineScope.launch {
context.shortToast(translation["saving_logs_toast"])
context.androidContext.contentResolver.openOutputStream(Uri.parse(uri))?.use {
@@ -84,7 +83,7 @@ class HomeLogs : Routes.Route() {
context.longToast(translation["saved_logs_success_toast"])
}.onFailure {
context.longToast(translation["saved_logs_failure_toast"])
context.log.error("Failed to save logs to $uri!", it)
context.log.error("Failed to save logs to ${'$'}uri!", it)
}
}
}
@@ -95,14 +94,13 @@ class HomeLogs : Routes.Route() {
})
}
}
@Suppress("DEPRECATION")
override val content: @Composable (NavBackStackEntry) -> Unit = {
val coroutineScope = rememberCoroutineScope()
val clipboardManager = LocalClipboardManager.current
val clipboard: ClipboardManager = LocalClipboardManager.current
var lineCount by remember { mutableIntStateOf(0) }
var logReader by remember { mutableStateOf<LogReader?>(null) }
var isRefreshing by remember { mutableStateOf(false) }
fun refreshLogs() {
coroutineScope.launch(Dispatchers.IO) {
runCatching {
@@ -120,16 +118,13 @@ class HomeLogs : Routes.Route() {
}
}
}
val pullRefreshState = rememberPullRefreshState(isRefreshing, onRefresh = {
refreshLogs()
})
LaunchedEffect(Unit) {
isRefreshing = true
refreshLogs()
}
Box(
modifier = Modifier
.fillMaxSize()
@@ -138,7 +133,8 @@ class HomeLogs : Routes.Route() {
modifier = Modifier
.background(MaterialTheme.colorScheme.surface)
.horizontalScroll(ScrollState(0)),
state = logListState
state = logListState,
contentPadding = PaddingValues(bottom = routes.bottomPadding)
) {
item {
if (lineCount == 0 && logReader != null) {
@@ -157,21 +153,22 @@ class HomeLogs : Routes.Route() {
})
}
logLine?.let { line ->
Box(modifier = Modifier
.fillMaxWidth()
.pointerInput(Unit) {
detectTapGestures(
onLongPress = {
coroutineScope.launch {
clipboardManager.setText(
AnnotatedString(
line.message
Box(
modifier = Modifier
.fillMaxWidth()
.animateContentSize()
.pointerInput(Unit) {
detectTapGestures(
onLongPress = {
coroutineScope.launch {
clipboard.setText(
AnnotatedString(line.message)
)
)
}
}
}
)
}) {
)
}
) {
Column(
modifier = Modifier
.padding(4.dp)
@@ -187,26 +184,22 @@ class HomeLogs : Routes.Route() {
LogLevel.ERROR, LogLevel.ASSERT -> Icons.Outlined.Report
LogLevel.INFO, LogLevel.VERBOSE -> Icons.Outlined.Info
LogLevel.WARN -> Icons.Outlined.Warning
else -> Icons.Outlined.Info
},
modifier = Modifier.size(16.dp),
contentDescription = null,
)
Text(
text = LogChannel.fromChannel(line.tag)?.shortName ?: line.tag,
modifier = Modifier.padding(start = 4.dp),
fontWeight = FontWeight.Bold,
fontSize = 12.sp,
)
Text(
text = line.dateTime,
modifier = Modifier.padding(start = 4.dp, end = 4.dp),
fontSize = 10.sp
)
}
Text(
text = line.message.trimIndent(),
lineHeight = 10.sp,
@@ -218,7 +211,6 @@ class HomeLogs : Routes.Route() {
}
}
}
PullRefreshIndicator(
refreshing = isRefreshing,
state = pullRefreshState,
@@ -226,7 +218,6 @@ class HomeLogs : Routes.Route() {
)
}
}
override val floatingActionButton: @Composable () -> Unit = {
val coroutineScope = rememberCoroutineScope()
Column(
@@ -244,7 +235,6 @@ class HomeLogs : Routes.Route() {
) {
Icon(Icons.Filled.KeyboardDoubleArrowUp, contentDescription = null)
}
FilledIconButton(
onClick = {
coroutineScope.launch {

View File

@@ -1,21 +1,67 @@
package me.rhunk.snapenhance.ui.manager.pages.home
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Help
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.material.icons.outlined.Widgets
import androidx.compose.material3.Button
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.LinkAnnotation
@@ -29,6 +75,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavBackStackEntry
@@ -41,21 +88,36 @@ import me.rhunk.snapenhance.common.ui.TopBarActionButton
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList
import me.rhunk.snapenhance.common.util.ktx.openLink
import me.rhunk.snapenhance.core.ui.Snapenhance
import me.rhunk.snapenhance.storage.getQuickTiles
import me.rhunk.snapenhance.storage.setQuickTiles
import me.rhunk.snapenhance.ui.manager.Routes
import me.rhunk.snapenhance.ui.manager.data.UpdateDownloader
import me.rhunk.snapenhance.ui.manager.data.Updater
import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper
import me.rhunk.snapenhance.ui.util.AlertDialogs
import me.rhunk.snapenhance.ui.util.scaleOnPress
import java.text.DateFormat
import kotlin.math.roundToInt
class HomeRootSection : Routes.Route() {
companion object {
val cardMargin = 10.dp
}
private lateinit var activityLauncherHelper: ActivityLauncherHelper
data class QaCard(val id: String, val name: String, val icon: ImageVector, val action: (Routes) -> Unit)
private val cardEntries by lazy {
val list = mutableListOf<QaCard>()
EnumQuickActions.entries.forEach { q ->
val name = context.translation["actions.${q.key}.name"]
list.add(QaCard(id = "quick.${q.key}", name = name, icon = q.icon, action = q.action))
}
EnumAction.entries.forEach { a ->
val name = context.translation["actions.${a.key}.name"]
list.add(QaCard(id = "action.${a.key}", name = name, icon = a.icon, action = { context.launchActionIntent(a) }))
}
list
}
private val cards by lazy {
EnumQuickActions.entries.map {
(context.translation["actions.${it.key}.name"] to it.icon) to it.action
@@ -69,11 +131,8 @@ class HomeRootSection : Routes.Route() {
}
}
}
@Composable
private fun InfoCard(
content: @Composable ColumnScope.() -> Unit,
) {
private fun InfoCard(content: @Composable ColumnScope.() -> Unit) {
OutlinedCard(
modifier = Modifier
.padding(start = cardMargin, end = cardMargin)
@@ -92,13 +151,14 @@ class HomeRootSection : Routes.Route() {
}
}
}
@Composable
fun ExternalLinkIcon(
modifier: Modifier = Modifier,
size: Dp = 32.dp,
imageVector: ImageVector,
onClick: (() -> Unit)? = null,
) {
val interactionSource = remember { MutableInteractionSource() }
Icon(
imageVector = imageVector,
contentDescription = null,
@@ -106,16 +166,66 @@ class HomeRootSection : Routes.Route() {
modifier = Modifier
.size(size)
.clip(RoundedCornerShape(50))
.scaleOnPress(interactionSource)
.then(
if (onClick != null)
Modifier.clickable(interactionSource = interactionSource, indication = LocalIndication.current) { onClick() }
else Modifier
)
.then(modifier)
)
}
private fun resolveTileKey(name: String): String {
val entry = cardEntries.firstOrNull { it.name == name }
return entry?.id ?: name
}
private fun getTileSpan(name: String): Pair<Int, Int> {
val prefs = context.sharedPreferences
val key = resolveTileKey(name)
val raw = prefs.getString("quick_tile_size_$key", null) ?: "1x1"
val parts = raw.split('x')
val w = parts.getOrNull(0)?.toIntOrNull()?.coerceIn(1, 3) ?: 1
val h = parts.getOrNull(1)?.toIntOrNull()?.coerceIn(1, 3) ?: 1
return w to h
}
private fun setTileSpan(name: String, w: Int, h: Int) {
val prefs = context.sharedPreferences
val key = resolveTileKey(name)
prefs.edit().putString("quick_tile_size_$key", "${w.coerceIn(1,3)}x${h.coerceIn(1,3)}").apply()
}
private fun clearTileSpan(name: String) {
val prefs = context.sharedPreferences
val key = resolveTileKey(name)
prefs.edit().remove("quick_tile_size_$key").apply()
}
private fun getTileOffset(name: String): Pair<Float, Float> {
val prefs = context.sharedPreferences
val key = resolveTileKey(name)
val raw = prefs.getString("quick_tile_offset_$key", null)
if (raw == null) return 0f to 0f
val parts = raw.split(',')
val x = parts.getOrNull(0)?.toFloatOrNull() ?: 0f
val y = parts.getOrNull(1)?.toFloatOrNull() ?: 0f
return x to y
}
private fun setTileOffset(name: String, x: Float, y: Float) {
val prefs = context.sharedPreferences
val key = resolveTileKey(name)
prefs.edit().putString("quick_tile_offset_$key", "$x,$y").apply()
}
private fun clearTileOffset(name: String) {
val prefs = context.sharedPreferences
val key = resolveTileKey(name)
prefs.edit().remove("quick_tile_offset_$key").apply()
}
override val title: @Composable (() -> Unit)? = {}
override val init: () -> Unit = {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
}
override val topBarActions: @Composable (RowScope.() -> Unit) = {
TopBarActionButton(
onClick = {
@@ -134,28 +244,35 @@ class HomeRootSection : Routes.Route() {
)
}
@OptIn(ExperimentalLayoutApi::class)
@OptIn(ExperimentalLayoutApi::class, ExperimentalAnimationApi::class, ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
override val content: @Composable (NavBackStackEntry) -> Unit = {
val avenirNext = remember {
FontFamily(
Font(R.font.avenir_next_medium, FontWeight.Medium)
)
FontFamily(Font(R.font.avenir_next_medium, FontWeight.Medium))
}
val selectedTiles = rememberAsyncMutableStateList(defaultValue = listOf()) {
context.database.getQuickTiles()
}
val latestUpdate by rememberAsyncMutableState(defaultValue = null) { Updater.latestRelease }
var showQuickActionsMenu by remember { mutableStateOf(false) }
var editMode by remember { mutableStateOf(false) }
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.verticalScroll(scrollState, enabled = !editMode)
) {
Icon(
imageVector = Snapenhance, contentDescription = null,
Text(
"PurrfectSnap",
modifier = Modifier
.fillMaxWidth()
.padding(all = 8.dp)
.align(Alignment.CenterHorizontally),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 48.sp,
fontWeight = FontWeight.Bold,
fontFamily = avenirNext,
textAlign = TextAlign.Center
)
Text(
text = translation.format(
"version_title",
@@ -165,45 +282,28 @@ class HomeRootSection : Routes.Route() {
fontFamily = avenirNext,
modifier = Modifier.align(Alignment.CenterHorizontally),
)
Row(
horizontalArrangement = Arrangement.spacedBy(
15.dp, Alignment.CenterHorizontally
),
horizontalArrangement = Arrangement.spacedBy(15.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(all = 5.dp)
) {
ExternalLinkIcon(
modifier = Modifier.clickable {
context.androidContext.openLink("https://t.me/snapenhance")
},
onClick = { context.androidContext.openLink("https://t.me/purrfectsnap") },
imageVector = ImageVector.vectorResource(id = R.drawable.ic_telegram),
)
ExternalLinkIcon(
modifier = Modifier.clickable {
context.androidContext.openLink("https://github.com/rhunk/SnapEnhance")
},
onClick = { context.androidContext.openLink("https://github.com/particle-box/PurrfectSnap") },
imageVector = ImageVector.vectorResource(id = R.drawable.ic_github),
)
ExternalLinkIcon(
modifier = Modifier.offset(x = (-3).dp).clickable {
context.androidContext.openLink("https://github.com/rhunk/SnapEnhance/wiki")
},
onClick = { context.androidContext.openLink("https://github.com/particle-box/PurrfectSnap/wiki") },
modifier = Modifier.offset(x = (-3).dp),
size = 40.dp,
imageVector = Icons.AutoMirrored.Default.Help,
imageVector = Icons.AutoMirrored.Filled.Help,
)
}
val selectedTiles = rememberAsyncMutableStateList(defaultValue = listOf()) {
context.database.getQuickTiles()
}
val latestUpdate by rememberAsyncMutableState(defaultValue = null) { Updater.latestRelease }
if (latestUpdate != null) {
Spacer(modifier = Modifier.height(10.dp))
InfoCard {
@@ -228,18 +328,65 @@ class HomeRootSection : Routes.Route() {
overflow = TextOverflow.Ellipsis,
)
}
Button(
modifier = Modifier.height(40.dp),
onClick = {
latestUpdate?.releaseUrl?.let { context.androidContext.openLink(it) }
val downloadState by UpdateDownloader.downloadState.collectAsState()
val downloadProgress by UpdateDownloader.downloadProgress.collectAsState()
val coroutineScope = rememberCoroutineScope()
AnimatedContent(
targetState = downloadState,
modifier = Modifier.height(40.dp)
) { state ->
when (state) {
UpdateDownloader.DownloadState.IDLE -> {
IconButton(
onClick = {
val latest = latestUpdate ?: return@IconButton
if (latest.workflowId == null) {
context.androidContext.openLink(latest.releaseUrl)
return@IconButton
}
val supportedAbis = android.os.Build.SUPPORTED_ABIS
var abiName: String? = null
for (abi in supportedAbis) {
when (abi) {
"arm64-v8a" -> {
abiName = "armv8"
break
}
"armeabi-v7a" -> {
abiName = "armv7"
break
}
}
}
if (abiName != null) {
val artifactName = "purrfectsnap-${abiName}-debug"
val downloadUrl = "https://nightly.link/particle-box/PurrfectSnap/actions/runs/${latest.workflowId}/$artifactName.zip"
UpdateDownloader.downloadAndInstall(context.androidContext, downloadUrl, "$artifactName.zip", coroutineScope)
} else {
android.widget.Toast.makeText(context.androidContext, "Your device architecture is not supported for automatic updates.", android.widget.Toast.LENGTH_LONG).show()
}
},
modifier = Modifier.scaleOnPress(remember { MutableInteractionSource() })
) {
Icon(imageVector = Icons.Default.Download, contentDescription = "Download")
}
}
UpdateDownloader.DownloadState.DOWNLOADING -> {
CircularProgressIndicator(progress = { downloadProgress })
}
UpdateDownloader.DownloadState.COMPLETED -> {
Icon(imageVector = Icons.Default.Check, contentDescription = "Completed")
}
UpdateDownloader.DownloadState.FAILED -> {
Icon(imageVector = Icons.Default.Close, contentDescription = "Failed")
}
}
) {
Text(text = translation["update_button"])
}
}
}
}
if (BuildConfig.DEBUG) {
Spacer(modifier = Modifier.height(10.dp))
InfoCard {
@@ -271,7 +418,7 @@ class HomeRootSection : Routes.Route() {
LinkAnnotation.Clickable(
"git_hash",
linkInteractionListener = {
context.androidContext.openLink("https://github.com/rhunk/SnapEnhance/commit/${BuildConfig.GIT_HASH}")
context.androidContext.openLink("https://github.com/particle-box/PurrfectSnap/commit/${BuildConfig.GIT_HASH}")
}
)
) {
@@ -285,18 +432,14 @@ class HomeRootSection : Routes.Route() {
}
}
}
Text(
text = buildSummary
)
Text(text = buildSummary)
Text(
fontSize = 12.sp,
text = remember {
translation.format(
"debug_build_summary_date",
"date" to DateFormat.getDateTimeInstance()
.format(BuildConfig.BUILD_TIMESTAMP),
"days" to ((System.currentTimeMillis() - BuildConfig.BUILD_TIMESTAMP) / 86400000).toInt()
.toString()
"date" to DateFormat.getDateTimeInstance().format(BuildConfig.BUILD_TIMESTAMP),
"days" to ((System.currentTimeMillis() - BuildConfig.BUILD_TIMESTAMP) / 86400000).toInt().toString()
)
},
lineHeight = 20.sp,
@@ -304,107 +447,393 @@ class HomeRootSection : Routes.Route() {
)
}
}
var showQuickActionsMenu by remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 20.dp, end = 10.dp, top = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
translation["quick_actions_title"], fontSize = 20.sp,
modifier = Modifier.weight(1f)
)
Box {
IconButton(
onClick = { showQuickActionsMenu = !showQuickActionsMenu },
Spacer(modifier = Modifier.height(12.dp))
AnimatedContent(targetState = selectedTiles.isNotEmpty(), label = "QuickActionsTitleAnim") { hasQuickActions ->
if (!hasQuickActions) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 4.dp)
) {
Icon(Icons.Default.MoreVert, contentDescription = null)
Surface(
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.secondaryContainer,
tonalElevation = 2.dp,
shadowElevation = 4.dp,
modifier = Modifier.align(Alignment.Center)
) {
Text(
translation["quick_actions_title"],
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 4.dp)
)
}
}
DropdownMenu(
expanded = showQuickActionsMenu,
onDismissRequest = { showQuickActionsMenu = false }
} else {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
cards.forEach { (card, _) ->
fun toggle(state: Boolean? = null) {
if (state?.let { !it } ?: selectedTiles.contains(card.first)) {
selectedTiles.remove(card.first)
Text(
translation["quick_actions_title"],
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Start,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.weight(1f)
)
IconButton(
onClick = { showQuickActionsMenu = true },
modifier = Modifier.align(Alignment.CenterVertically)
) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_manage),
contentDescription = "Manage Quick Actions"
)
}
FilterChip(
selected = editMode,
onClick = { editMode = !editMode },
label = { Text(if (editMode) "Done" else "Edit") },
leadingIcon = { Icon(Icons.Filled.DragHandle, contentDescription = null) }
)
}
}
}
if (selectedTiles.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(260.dp),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Outlined.Widgets,
contentDescription = "Quick Actions",
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(10.dp))
Text(
text = "No quick actions added yet",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(10.dp))
Button(
onClick = { showQuickActionsMenu = true },
modifier = Modifier.align(Alignment.CenterHorizontally)
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = "Add Quick Action",
modifier = Modifier.size(22.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(text = "Add")
}
}
}
} else {
val spacing = 6.dp
var spanTick by remember { mutableIntStateOf(0) }
BoxWithConstraints(
modifier = Modifier.fillMaxWidth().padding(horizontal = cardMargin)
) {
val density = LocalDensity.current
val baseCell = remember { (maxWidth - (spacing * 2)) / 3f }
val baseCellPx = with(density) { baseCell.toPx() }
val (tilePositions, totalHeight) = remember(selectedTiles, spanTick) {
val positions = mutableMapOf<String, Offset>()
var currentX = 0f
var currentY = 0f
var rowMaxHeight = 0f
val screenWidthPx = with(density) { maxWidth.toPx() }
selectedTiles.forEach { tileName ->
val card = cards.entries.find { entry -> entry.key.first == tileName }!!.key
val (wSpan, hSpan) = getTileSpan(card.first)
val tileWidthPx = with(density) { (baseCell * wSpan + spacing * (wSpan - 1)).toPx() }
val tileHeightPx = with(density) { (baseCell * hSpan + spacing * (hSpan - 1)).toPx() }
if (currentX + tileWidthPx > screenWidthPx) {
currentX = 0f
currentY += rowMaxHeight
rowMaxHeight = 0f
}
positions[tileName] = Offset(currentX, currentY)
currentX += tileWidthPx + with(density) { spacing.toPx() }
if (tileHeightPx > rowMaxHeight) {
rowMaxHeight = tileHeightPx
}
}
positions to (currentY + rowMaxHeight)
}
Box(modifier = Modifier.height(with(density) { totalHeight.toDp() })) {
remember(selectedTiles.size, context.translation.loadedLocale) {
selectedTiles.mapNotNull {
cards.entries.find { entry -> entry.key.first == it }
}
}.forEach { (card, action) ->
val interactionSource = remember { MutableInteractionSource() }
val _tick = spanTick
val (wSpan, hSpan) = getTileSpan(card.first)
val tileWidth = baseCell * wSpan + spacing * (wSpan - 1)
val tileHeight = baseCell * hSpan + spacing * (hSpan - 1)
val tileWidthPx = with(density) { tileWidth.toPx() }
val tileHeightPx = with(density) { tileHeight.toPx() }
var offsetX by remember(card.first) { mutableStateOf(0f) }
var offsetY by remember(card.first) { mutableStateOf(0f) }
var isDragging by remember { mutableStateOf(false) }
LaunchedEffect(card.first, spanTick) {
val (x, y) = getTileOffset(card.first)
if (x != 0f || y != 0f) {
offsetX = x
offsetY = y
} else {
selectedTiles.add(0, card.first)
}
context.coroutineScope.launch {
context.database.setQuickTiles(selectedTiles)
val pos = tilePositions[card.first]
if (pos != null) {
offsetX = pos.x
offsetY = pos.y
setTileOffset(card.first, offsetX, offsetY)
}
}
}
DropdownMenuItem(onClick = { toggle() }, text = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(all = 5.dp)
) {
Checkbox(
checked = selectedTiles.contains(card.first),
onCheckedChange = {
toggle(it)
val animatedOffsetX by animateFloatAsState(
targetValue = offsetX,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
),
label = "offsetX"
)
val animatedOffsetY by animateFloatAsState(
targetValue = offsetY,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
),
label = "offsetY"
)
val currentOffsetX = if (isDragging) offsetX else animatedOffsetX
val currentOffsetY = if (isDragging) offsetY else animatedOffsetY
val baseModifier = Modifier
.offset { IntOffset(currentOffsetX.roundToInt(), currentOffsetY.roundToInt()) }
.width(tileWidth)
.height(tileHeight)
.padding(all = 6.dp)
val editModifier = baseModifier.then(
Modifier.pointerInput(card.first, tileWidthPx, tileHeightPx) {
var originalOffsetX = 0f
var originalOffsetY = 0f
detectDragGestures(
onDragStart = {
isDragging = true
originalOffsetX = offsetX
originalOffsetY = offsetY
},
onDrag = { change, dragAmount ->
change.consume()
offsetX += dragAmount.x
offsetY += dragAmount.y
},
onDragEnd = {
isDragging = false
var targetTile: String? = null
var maxOverlap = 0f
val tileRect = Rect(Offset(offsetX, offsetY), Size(tileWidthPx, tileHeightPx))
for (otherTileName in selectedTiles) {
if (otherTileName == card.first) continue
val (otherOffsetX, otherOffsetY) = getTileOffset(otherTileName)
val (otherWSpan, otherHSpan) = getTileSpan(otherTileName)
val otherTileWidth = baseCell * otherWSpan + spacing * (otherWSpan - 1)
val otherTileHeight = baseCell * otherHSpan + spacing * (otherHSpan - 1)
val otherRect = Rect(Offset(otherOffsetX, otherOffsetY), Size(with(density) { otherTileWidth.toPx() }, with(density) { otherTileHeight.toPx() }))
val intersectRect = tileRect.intersect(otherRect)
val overlapArea = intersectRect.width * intersectRect.height
if (overlapArea > maxOverlap) {
maxOverlap = overlapArea
targetTile = otherTileName
}
}
if (targetTile != null) {
val (wSpan, hSpan) = getTileSpan(card.first)
val (targetWSpan, targetHSpan) = getTileSpan(targetTile!!)
if (wSpan == targetWSpan && hSpan == targetHSpan) {
// swap
val (targetOffsetX, targetOffsetY) = getTileOffset(targetTile!!)
setTileOffset(card.first, targetOffsetX, targetOffsetY)
setTileOffset(targetTile!!, originalOffsetX, originalOffsetY)
spanTick++ // this will trigger recomposition for all tiles
} else {
// revert
offsetX = originalOffsetX
offsetY = originalOffsetY
}
} else {
setTileOffset(card.first, offsetX, offsetY)
}
}
)
Text(text = card.first)
}
})
)
val viewModifier = baseModifier.then(Modifier.scaleOnPress(interactionSource))
if (editMode) {
ElevatedCard(
modifier = editModifier
) {
Box(Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(all = 5.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceEvenly,
) {
Icon(
imageVector = card.second, contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(50.dp)
)
Text(
text = card.first,
lineHeight = 16.sp,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
overflow = TextOverflow.Ellipsis,
)
}
// Drag handle for resizing
var dxAccResize by remember(card.first, spanTick) { mutableStateOf(0f) }
var dyAccResize by remember(card.first, spanTick) { mutableStateOf(0f) }
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.size(28.dp)
.pointerInput(card.first, spanTick) {
detectDragGestures(
onDragStart = {
dxAccResize = 0f
dyAccResize = 0f
},
onDrag = { change, dragAmount ->
change.consume()
dxAccResize += dragAmount.x
dyAccResize += dragAmount.y
var newW = wSpan
var newH = hSpan
val step = baseCellPx / 2f
while (dxAccResize > step) {
newW = (wSpan + 1).coerceIn(1, 3)
dxAccResize -= step
}
while (dxAccResize < -step) {
newW = (wSpan - 1).coerceIn(1, 3)
dxAccResize += step
}
while (dyAccResize > step) {
newH = (hSpan + 1).coerceIn(1, 3)
dyAccResize -= step
}
while (dyAccResize < -step) {
newH = (hSpan - 1).coerceIn(1, 3)
dyAccResize += step
}
if (newW != wSpan || newH != hSpan) {
setTileSpan(card.first, newW, newH)
spanTick++
selectedTiles.forEach { clearTileOffset(it) }
}
}
)
},
contentAlignment = Alignment.Center
) {
Icon(Icons.Filled.DragHandle, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
} else {
ElevatedCard(
modifier = viewModifier,
onClick = { action(routes) },
interactionSource = interactionSource
) {
Box(Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(all = 5.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceEvenly,
) {
Icon(
imageVector = card.second, contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(50.dp)
)
Text(
text = card.first,
lineHeight = 16.sp,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
}
}
}
FlowRow(
modifier = Modifier
.padding(all = cardMargin)
.fillMaxWidth(),
maxItemsInEachRow = 3,
horizontalArrangement = Arrangement.SpaceEvenly,
) {
val tileHeight = LocalDensity.current.run {
remember { (context.androidContext.resources.displayMetrics.widthPixels / 3).toDp() - cardMargin / 2 }
}
remember(selectedTiles.size, context.translation.loadedLocale) {
selectedTiles.mapNotNull {
cards.entries.find { entry -> entry.key.first == it }
}
}.forEach { (card, action) ->
ElevatedCard(
modifier = Modifier
.height(tileHeight)
.weight(1f)
.padding(all = 6.dp),
onClick = { action(routes) }
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(all = 5.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceEvenly,
) {
Icon(
imageVector = card.second, contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(50.dp)
)
Text(
text = card.first,
lineHeight = 16.sp,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
overflow = TextOverflow.Ellipsis,
)
if (showQuickActionsMenu) {
QuickActionsDialog(
quickActions = cards,
selectedQuickActions = selectedTiles,
onDismiss = { showQuickActionsMenu = false },
onSave = { newList ->
val previous = selectedTiles.toList()
val removed = previous.filter { it !in newList }
removed.forEach { clearTileSpan(it); clearTileOffset(it) }
selectedTiles.clear()
selectedTiles.addAll(newList)
context.coroutineScope.launch {
context.database.setQuickTiles(selectedTiles)
}
showQuickActionsMenu = false
}
}
)
}
Spacer(modifier = Modifier.height(routes.bottomPadding))
}
}
}

View File

@@ -7,10 +7,16 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.Brightness4
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -21,36 +27,75 @@ import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.launch
import me.rhunk.snapenhance.common.action.EnumAction
import me.rhunk.snapenhance.common.bridge.InternalFileHandleType
import me.rhunk.snapenhance.common.ui.ThemeChooserDialog
import me.rhunk.snapenhance.common.ui.ThemeMode
import me.rhunk.snapenhance.common.ui.ThemePreferences
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState
import me.rhunk.snapenhance.ui.manager.Routes
import me.rhunk.snapenhance.ui.setup.Requirements
import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper
import me.rhunk.snapenhance.ui.util.AlertDialogs
import me.rhunk.snapenhance.ui.util.saveFile
import androidx.work.WorkManager
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.Constraints
import androidx.work.NetworkType
import me.rhunk.snapenhance.task.UpdateCheckWorker
import java.util.concurrent.TimeUnit
class HomeSettings : Routes.Route() {
private lateinit var activityLauncherHelper: ActivityLauncherHelper
private val dialogs by lazy { AlertDialogs(context.translation) }
private fun scheduleUpdateCheck() {
val workManager = WorkManager.getInstance(context.androidContext)
if (context.config.root.global.updateSettings.autoUpdateCheck.get()) {
val frequency = context.config.root.global.updateSettings.updateCheckFrequency.get()
val repeatInterval = when (frequency) {
"daily" -> 1L
"weekly" -> 7L
"monthly" -> 30L
else -> 1L
}
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val workRequest = PeriodicWorkRequestBuilder<UpdateCheckWorker>(repeatInterval, TimeUnit.DAYS)
.setConstraints(constraints)
.build()
workManager.enqueueUniquePeriodicWork(
"snapenhance_update_check",
ExistingPeriodicWorkPolicy.REPLACE,
workRequest
)
} else {
workManager.cancelUniqueWork("snapenhance_update_check")
}
}
override val init: () -> Unit = {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
}
@Composable
private fun RowTitle(title: String) {
Text(text = title, modifier = Modifier.padding(16.dp), fontSize = 20.sp, fontWeight = FontWeight.Bold)
}
@Composable
private fun PreferenceToggle(sharedPreferences: SharedPreferences, key: String, text: String) {
val realKey = "debug_$key"
var value by remember { mutableStateOf(sharedPreferences.getBoolean(realKey, false)) }
val hapticFeedback = LocalHapticFeedback.current
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 55.dp)
.clickable {
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
value = !value
sharedPreferences
.edit() {
@@ -62,18 +107,19 @@ class HomeSettings : Routes.Route() {
) {
Text(text = text, modifier = Modifier.padding(end = 16.dp), fontSize = 14.sp)
Switch(checked = value, onCheckedChange = {
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
value = it
sharedPreferences.edit().putBoolean(realKey, it).apply()
}, modifier = Modifier.padding(end = 26.dp))
}
}
@Composable
private fun RowAction(key: String, requireConfirmation: Boolean = false, action: () -> Unit) {
var confirmationDialog by remember {
mutableStateOf(false)
}
fun takeAction() {
if (requireConfirmation) {
confirmationDialog = true
@@ -81,7 +127,6 @@ class HomeSettings : Routes.Route() {
action()
}
}
if (requireConfirmation && confirmationDialog) {
Dialog(onDismissRequest = { confirmationDialog = false }) {
dialogs.ConfirmDialog(title = context.translation["manager.dialogs.action_confirm.title"], onConfirm = {
@@ -92,7 +137,6 @@ class HomeSettings : Routes.Route() {
})
}
}
ShiftedRow(
modifier = Modifier
.fillMaxWidth()
@@ -120,7 +164,6 @@ class HomeSettings : Routes.Route() {
}
}
}
@Composable
private fun ShiftedRow(
modifier: Modifier = Modifier,
@@ -137,11 +180,58 @@ class HomeSettings : Routes.Route() {
@OptIn(ExperimentalMaterial3Api::class)
override val content: @Composable (NavBackStackEntry) -> Unit = {
val contextC = LocalContext.current
val scope = rememberCoroutineScope()
val themeMode by ThemePreferences.getThemeModeFlow(contextC).collectAsState(initial = ThemeMode.SYSTEM)
var showThemeDialog by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
// APP THEME (Popup)
Spacer(Modifier.height(20.dp))
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.clickable { showThemeDialog = true },
shape = MaterialTheme.shapes.medium,
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Row(
Modifier
.fillMaxWidth()
.padding(22.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Filled.Brightness4,
contentDescription = "Theme",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(26.dp)
)
Spacer(modifier = Modifier.width(18.dp))
Column(Modifier.weight(1f)) {
Text("App Theme", fontWeight = FontWeight.Medium, fontSize = 16.sp)
Text(themeMode.displayName, color = MaterialTheme.colorScheme.primary, fontSize = 14.sp)
}
}
}
if (showThemeDialog) {
ThemeChooserDialog(
selected = themeMode,
onSelect = { mode ->
scope.launch {
ThemePreferences.setThemeMode(contextC, mode)
}
},
onDismiss = { showThemeDialog = false }
)
}
Spacer(Modifier.height(20.dp))
RowTitle(title = translation["actions_title"])
EnumAction.entries.forEach { enumAction ->
RowAction(key = enumAction.key) {
@@ -154,6 +244,117 @@ class HomeSettings : Routes.Route() {
RowAction(key = "change_language") {
context.checkForRequirements(Requirements.LANGUAGE)
}
RowTitle(title = "UI Settings")
ShiftedRow {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 55.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = "Haptic Feedback")
var hapticFeedbackEnabled by remember { mutableStateOf(context.config.root.global.uiSettings.hapticFeedback.getNullable() ?: true) }
val hapticFeedback = LocalHapticFeedback.current
Switch(
checked = hapticFeedbackEnabled,
onCheckedChange = {
if (it) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
hapticFeedbackEnabled = it
context.config.root.global.uiSettings.hapticFeedback.set(it)
context.config.writeConfig()
},
modifier = Modifier.padding(end = 26.dp)
)
}
}
RowTitle(title = translation["updates_title"])
ShiftedRow {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
var autoUpdateCheck by remember { mutableStateOf(context.config.root.global.updateSettings.autoUpdateCheck.getNullable() ?: true) }
var selectedFrequency by remember { mutableStateOf(context.config.root.global.updateSettings.updateCheckFrequency.getNullable() ?: "weekly") }
var frequencyMenuExpanded by remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 55.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text(text = translation["auto_update_check"])
if (autoUpdateCheck) {
Text(
text = translation["update_check_frequency_" + selectedFrequency],
fontSize = 12.sp,
fontWeight = FontWeight.Light
)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Box {
IconButton(
onClick = { frequencyMenuExpanded = true },
enabled = autoUpdateCheck,
modifier = Modifier.alpha(if (autoUpdateCheck) 1f else 0f)
) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = translation["update_check_frequency"]
)
}
if (autoUpdateCheck) {
DropdownMenu(
expanded = frequencyMenuExpanded,
onDismissRequest = { frequencyMenuExpanded = false }
) {
val frequencies = remember { listOf("daily", "weekly", "monthly") }
frequencies.forEach { frequency ->
DropdownMenuItem(
text = { Text(text = translation["update_check_frequency_" + frequency]) },
onClick = {
selectedFrequency = frequency
context.config.root.global.updateSettings.updateCheckFrequency.set(frequency)
context.config.writeConfig()
scheduleUpdateCheck()
frequencyMenuExpanded = false
}
)
}
}
}
}
val hapticFeedback = LocalHapticFeedback.current
Switch(
checked = autoUpdateCheck,
onCheckedChange = {
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
autoUpdateCheck = it
context.config.root.global.updateSettings.autoUpdateCheck.set(it)
if (it && context.config.root.global.updateSettings.updateCheckFrequency.getNullable() == null) {
context.config.root.global.updateSettings.updateCheckFrequency.set("weekly")
}
context.config.writeConfig()
scheduleUpdateCheck()
},
modifier = Modifier.padding(end = 26.dp)
)
}
}
}
}
RowTitle(title = translation["message_logger_title"])
ShiftedRow {
Column(
@@ -225,7 +426,6 @@ class HomeSettings : Routes.Route() {
}
}
}
RowTitle(title = translation["debug_title"])
Row(
horizontalArrangement = Arrangement.SpaceBetween,
@@ -238,7 +438,6 @@ class HomeSettings : Routes.Route() {
.padding(start = 26.dp)
) {
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
@@ -250,7 +449,6 @@ class HomeSettings : Routes.Route() {
readOnly = true,
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable)
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
InternalFileHandleType.entries.forEach { fileType ->
DropdownMenuItem(onClick = {
@@ -285,9 +483,10 @@ class HomeSettings : Routes.Route() {
PreferenceToggle(context.sharedPreferences, key = "test_mode", text = "Test Mode (FOR DEBUGGING ONLY)")
PreferenceToggle(context.sharedPreferences, key = "disable_feature_loading", text = "Disable Feature Loading")
PreferenceToggle(context.sharedPreferences, key = "disable_mapper", text = "Disable Auto Mapper")
PreferenceToggle(context.sharedPreferences, key = "disable_bypass_indicator", text = "Disable Bypass Status Indicator")
}
}
Spacer(modifier = Modifier.height(50.dp))
Spacer(modifier = Modifier.height(routes.bottomPadding))
}
}
}
}

View File

@@ -0,0 +1,102 @@
package me.rhunk.snapenhance.ui.manager.pages.home
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import androidx.compose.ui.text.style.TextAlign
@Composable
fun QuickActionsDialog(
quickActions: Map<Pair<String, ImageVector>, Any>,
selectedQuickActions: List<String>,
onDismiss: () -> Unit,
onSave: (List<String>) -> Unit
) {
val selected = remember { mutableStateListOf(*selectedQuickActions.toTypedArray()) }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = "Edit Quick Actions",
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
)
},
text = {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
) {
Text(
text = "Select and size your quick actions.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(bottom = 16.dp)
.fillMaxWidth()
)
quickActions.keys.forEach { (name, icon) ->
val isSelected = selected.contains(name)
ListItem(
modifier = Modifier
.clickable {
if (isSelected) selected.remove(name) else selected.add(name)
}
.fillMaxWidth(),
headlineContent = {
Text(
name,
color = MaterialTheme.colorScheme.onSurface
)
},
leadingContent = {
Icon(
imageVector = icon,
contentDescription = name,
tint = MaterialTheme.colorScheme.primary
)
},
trailingContent = {
Checkbox(
checked = isSelected,
onCheckedChange = { isChecked ->
if (isChecked) selected.add(name) else selected.remove(name)
}
)
}
)
}
}
},
confirmButton = {
Button(
onClick = { onSave(selected.toList()) },
shape = MaterialTheme.shapes.medium
) {
Text("Save")
}
},
dismissButton = {
TextButton(
onClick = onDismiss,
shape = MaterialTheme.shapes.medium
) {
Text("Cancel")
}
},
containerColor = MaterialTheme.colorScheme.surface
)
}

View File

@@ -1,6 +1,7 @@
package me.rhunk.snapenhance.ui.manager.pages.location
import android.os.Parcel
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -8,11 +9,13 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.DeleteOutline
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.EditLocation
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -99,7 +102,6 @@ class BetterLocationRoot : Routes.Route() {
}
} ?: friendsLocation
}
ElevatedCard(
shape = MaterialTheme.shapes.large,
modifier = Modifier.padding(top = 32.dp, bottom = 32.dp)
@@ -149,11 +151,24 @@ class BetterLocationRoot : Routes.Route() {
}
}
@Composable
private fun ThemedEditLocationButton(onClick: () -> Unit) {
FilledIconButton(
modifier = Modifier.size(40.dp),
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.surface,
contentColor = if (isSystemInDarkTheme()) Color.White else Color(0xFF151A1A),
),
onClick = onClick
) {
Icon(Icons.Default.EditLocation, contentDescription = null)
}
}
override val content: @Composable (NavBackStackEntry) -> Unit = {
val coordinatesProperty = remember {
context.config.root.global.betterLocation.getPropertyPair("coordinates")
}
val updateDispatcher = rememberAsyncUpdateDispatcher()
val savedCoordinates = rememberAsyncMutableStateList(
defaultValue = listOf(),
@@ -164,17 +179,14 @@ class BetterLocationRoot : Routes.Route() {
var showMap by remember { mutableStateOf(false) }
var addSavedCoordinateDialog by remember { mutableStateOf(false) }
var showTeleportDialog by remember { mutableStateOf(false) }
val marker = remember { mutableStateOf<Marker?>(null) }
val mapView = remember { mutableStateOf<MapView?>(null) }
var spoofedCoordinates by remember(showTeleportDialog, showMap) { mutableStateOf(coordinatesProperty.value.get() as? Pair<*, *>) }
fun addSavedCoordinate(id: Int?, locationCoordinates: LocationCoordinates, onSuccess: suspend (id: Int) -> Unit = {}) {
context.coroutineScope.launch {
onSuccess(context.database.addOrUpdateLocationCoordinate(id, locationCoordinates))
}
}
if (showTeleportDialog) {
me.rhunk.snapenhance.ui.util.Dialog(
properties = DialogProperties(usePlatformDefaultWidth = false),
@@ -189,7 +201,6 @@ class BetterLocationRoot : Routes.Route() {
}
)
}
Column(
modifier = Modifier
.fillMaxSize()
@@ -207,7 +218,6 @@ class BetterLocationRoot : Routes.Route() {
.fillMaxWidth()
.padding(8.dp)
)
if (addSavedCoordinateDialog) {
me.rhunk.snapenhance.ui.util.Dialog(
onDismissRequest = { addSavedCoordinateDialog = false },
@@ -230,7 +240,6 @@ class BetterLocationRoot : Routes.Route() {
}
)
}
if (showMap) {
me.rhunk.snapenhance.ui.util.Dialog(
onDismissRequest = { showMap = false },
@@ -249,13 +258,12 @@ class BetterLocationRoot : Routes.Route() {
}
)
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
.clipToBounds()
.clipToBounds(),
contentPadding = PaddingValues(bottom = routes.bottomPadding)
) {
item {
@Composable
fun ConfigToggle(
@@ -345,7 +353,6 @@ class BetterLocationRoot : Routes.Route() {
val isSelected = spoofedCoordinates == mutableCoordinates.latitude to mutableCoordinates.longitude
var showDeleteDialog by remember { mutableStateOf(false) }
var showEditDialog by remember { mutableStateOf(false) }
fun setSpoofedCoordinates() {
spoofedCoordinates = mutableCoordinates.latitude to mutableCoordinates.longitude
coordinatesProperty.value.setAny(spoofedCoordinates)
@@ -353,7 +360,6 @@ class BetterLocationRoot : Routes.Route() {
context.config.writeConfig()
}
}
if (showDeleteDialog) {
me.rhunk.snapenhance.ui.util.Dialog(
onDismissRequest = { showDeleteDialog = false },
@@ -373,7 +379,6 @@ class BetterLocationRoot : Routes.Route() {
}
)
}
if (showEditDialog) {
me.rhunk.snapenhance.ui.util.Dialog(
onDismissRequest = { showEditDialog = false },
@@ -401,7 +406,6 @@ class BetterLocationRoot : Routes.Route() {
}
)
}
ElevatedCard(
onClick = {
mutableCoordinates = coordinates
@@ -447,7 +451,7 @@ class BetterLocationRoot : Routes.Route() {
FilledIconButton(onClick = {
showEditDialog = true
}) {
Icon(Icons.Default.Edit, contentDescription = "Delete")
Icon(Icons.Default.Edit, contentDescription = "Edit")
}
Spacer(modifier = Modifier.width(4.dp))
FilledIconButton(onClick = {
@@ -461,4 +465,4 @@ class BetterLocationRoot : Routes.Route() {
}
}
}
}
}

View File

@@ -0,0 +1,246 @@
package me.rhunk.snapenhance.ui.manager.pages.scripting
import androidx.compose.ui.Alignment
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Public
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.material.icons.filled.Error
import androidx.core.net.toUri
import com.google.gson.JsonParser
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import me.rhunk.snapenhance.common.util.ktx.getUrlFromClipboard
import me.rhunk.snapenhance.storage.addRepo
import me.rhunk.snapenhance.storage.getRepositories
import me.rhunk.snapenhance.storage.removeRepo
import me.rhunk.snapenhance.ui.manager.Routes
import me.rhunk.snapenhance.ui.manager.components.AestheticDialog
import okhttp3.OkHttpClient
class ManageScriptReposSection : Routes.Route() {
private val refreshTrigger = mutableStateOf(0)
private val okHttpClient by lazy { OkHttpClient() }
private fun extractRepoInfo(url: String): Pair<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 = "Invalid Repository",
text = errorDialogMessage,
icon = Icons.Default.Error,
confirmButtonText = "OK",
onConfirm = { showErrorDialog = false }
)
}
ExtendedFloatingActionButton(onClick = { showAddDialog = true }) {
Text("Add Repository")
}
if (showAddDialog) {
val coroutineScope = rememberCoroutineScope { Dispatchers.IO }
var url by remember { mutableStateOf("") }
var loading by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = { showAddDialog = false },
title = { Text("Add Repository URL") },
text = {
val focusRequester = remember { FocusRequester() }
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.onGloballyPositioned { focusRequester.requestFocus() },
value = url,
onValueChange = { url = it },
label = { Text("Repository URL") }
)
LaunchedEffect(Unit) {
context.androidContext.getUrlFromClipboard()?.let { url = it }
}
},
confirmButton = {
Button(
enabled = !loading && url.isNotBlank(),
onClick = {
loading = true
coroutineScope.launch {
runCatching {
var modifiedUrl = url
if (url.startsWith("https://github.com/")) {
val splitUrl = modifiedUrl.removePrefix("https://github.com/").split("/")
val repoName = splitUrl[0] + "/" + splitUrl[1]
okHttpClient.newCall(
okhttp3.Request.Builder().url("https://api.github.com/repos/$repoName").build()
).execute().use { response ->
if (!response.isSuccessful) {
throw Exception("Failed to fetch default branch: ${response.code}")
}
val json = response.body?.string() ?: throw Exception("Empty response")
val defaultBranch = Regex("\"default_branch\":\"([^\"]+)\"").find(json)?.groupValues?.get(1)
?: throw Exception("No default_branch field")
modifiedUrl = "https://raw.githubusercontent.com/$repoName/$defaultBranch/"
}
}
val indexUrl = modifiedUrl.toUri().buildUpon().appendPath("index.json").build().toString()
val request = okhttp3.Request.Builder().url(indexUrl).build()
val isValid = okHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw Exception("Failed to fetch index.json: ${response.code}")
val indexJson = response.body?.string() ?: throw Exception("Empty index.json")
JsonParser.parseString(indexJson).asJsonObject.has("scripts")
}
if (isValid) {
context.database.addRepo("script", modifiedUrl)
context.shortToast("Repository added successfully!")
showAddDialog = false
refreshTrigger.value++
} else {
errorDialogMessage = "This does not appear to be a valid Script repository."
showErrorDialog = true
}
}.onFailure {
context.log.error("Failed to add repository", it)
context.shortToast("Failed to add repository: ${it.message}")
}
loading = false
}
}
) {
if (loading) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
} else {
Text("Add")
}
}
}
)
}
}
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
val repositories by remember(refreshTrigger.value) {
mutableStateOf<List<String>>(runBlocking { context.database.getRepositories("script") })
}
if (repositories.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "No repositories added",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface
)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 8.dp, top = 8.dp, end = 8.dp, bottom = 8.dp + routes.bottomPadding),
) {
items(repositories) { url ->
val (repoName, author) = remember(url) { extractRepoInfo(url) }
ElevatedCard(
modifier = Modifier.padding(bottom = 8.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Public,
contentDescription = null,
modifier = Modifier.size(40.dp),
tint = MaterialTheme.colorScheme.primary
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = repoName,
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp
)
Text(
text = author,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
var showRemoveDialog by remember { mutableStateOf(false) }
Button(
onClick = { showRemoveDialog = true }
) {
Text("Remove")
}
AnimatedVisibility(visible = showRemoveDialog) {
AlertDialog(
onDismissRequest = { showRemoveDialog = false },
title = { Text("Remove Repository") },
text = { Text("Are you sure you want to remove this repository?") },
confirmButton = {
Button(
onClick = {
context.database.removeRepo("script", url)
showRemoveDialog = false
refreshTrigger.value++
}
) {
Text("Remove")
}
},
dismissButton = {
Button(onClick = { showRemoveDialog = false }) {
Text("Cancel")
}
}
)
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,305 @@
@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
package me.rhunk.snapenhance.ui.manager.pages.scripting
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.animation.animateContentSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Code
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import kotlinx.coroutines.*
import me.rhunk.snapenhance.common.util.ktx.openLink
import me.rhunk.snapenhance.storage.getRepositories
import okhttp3.OkHttpClient
import okhttp3.Request
data class ScriptRepoManifest(
val scripts: List<ScriptRepoEntry>
)
data class ScriptRepoEntry(
val name: String,
val author: String? = null,
val description: String? = null,
val version: String? = null,
val filepath: String
)
@Composable
fun ScriptCatalog(root: ScriptingRootSection) {
val context = root.context
val coroutineScope = rememberCoroutineScope()
val okHttpClient = remember { OkHttpClient() }
val gson = remember { context.gson }
var repositories by remember { mutableStateOf<List<String>>(emptyList()) }
var repoIndexes by remember { mutableStateOf<Map<String, ScriptRepoManifest>>(emptyMap()) }
var isLoading by remember { mutableStateOf(false) }
fun refreshIndexes() {
coroutineScope.launch(Dispatchers.IO) {
isLoading = true
val repos = context.database.getRepositories("script")
withContext(Dispatchers.Main) {
repositories = repos
}
if (repos.isNotEmpty()) {
val newIndexes = mutableMapOf<String, ScriptRepoManifest>()
repos.forEach { repoRoot ->
val indexUrl = if (repoRoot.endsWith("/")) "${repoRoot}index.json" else "$repoRoot/index.json"
try {
val req = Request.Builder().url(indexUrl).build()
okHttpClient.newCall(req).execute().use { response ->
if (response.isSuccessful) {
response.body?.charStream()?.let { reader ->
val parsed = gson.fromJson(reader, ScriptRepoManifest::class.java)
if (!parsed.scripts.isNullOrEmpty()) {
newIndexes[repoRoot] = parsed
}
}
}
}
} catch (_: Exception) {}
}
withContext(Dispatchers.Main) {
repoIndexes = newIndexes
isLoading = false
}
} else {
withContext(Dispatchers.Main) {
isLoading = false
}
}
}
}
LaunchedEffect(Unit) { refreshIndexes() }
val allScripts = repoIndexes.entries.flatMap { (repoUrl, manifest) ->
manifest.scripts.map { repoUrl to it }
}
suspend fun isScriptInstalled(scriptName: String): Boolean {
return try {
val installedScripts = context.scriptManager.getSyncedModules()
installedScripts.any { it.name.equals(scriptName, ignoreCase = true) }
} catch (e: Exception) {
false
}
}
fun downloadScript(repoUrl: String, entry: ScriptRepoEntry) {
coroutineScope.launch(Dispatchers.IO) {
if (isScriptInstalled(entry.name)) {
withContext(Dispatchers.Main) {
context.shortToast("Script already installed!")
}
return@launch
}
val rawUrl = if (repoUrl.endsWith("/")) repoUrl + entry.filepath else repoUrl + "/" + entry.filepath
if (root.isScriptInstalledByUrl(rawUrl)) {
withContext(Dispatchers.Main) {
context.shortToast("Script already installed!")
}
return@launch
}
try {
val req = Request.Builder().url(rawUrl).build()
okHttpClient.newCall(req).execute().use { response ->
if (!response.isSuccessful) {
withContext(Dispatchers.Main) { context.shortToast("Failed download: ${response.code}") }
return@use
}
val content = response.body?.bytes()
if (content != null) {
val folder = context.scriptManager.getScriptsFolder()
if (folder != null) {
val file = folder.createFile("application/javascript", "${entry.name}.js")
if (file != null) {
context.androidContext.contentResolver.openOutputStream(file.uri)?.use { output ->
output.write(content)
}
withContext(Dispatchers.Main) {
context.shortToast("Script downloaded!")
root.reloadDispatcher.dispatch()
}
} else {
withContext(Dispatchers.Main) {
context.shortToast("Could not create file.")
}
}
} else {
withContext(Dispatchers.Main) {
context.shortToast("No scripts folder selected.")
}
}
}
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
context.shortToast("Error: ${e.localizedMessage}")
}
}
}
}
if (repositories.isEmpty() && !isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(16.dp)
) {
Text(
text = "No repositories 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 = "Here you can find a list of repos: ",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Link",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable {
context.androidContext.openLink(
"https://github.com/rhunk/SnapEnhance/blob/dev/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/scripting/ScriptRepos.md"
)
}
)
}
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 8.dp, top = 8.dp, end = 8.dp, bottom = 8.dp + root.routes.bottomPadding)
) {
item {
if (isLoading) {
Box(modifier = Modifier.fillMaxWidth().padding(8.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else if (allScripts.isEmpty() && repositories.isNotEmpty()) {
Text(
text = "No scripts available from any repo.",
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
textAlign = TextAlign.Center,
fontSize = 15.sp,
fontWeight = FontWeight.Light,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
items(allScripts) { (repoUrl, entry) ->
var isDownloading by remember { mutableStateOf(false) }
var isAlreadyInstalled by remember { mutableStateOf(false) }
LaunchedEffect(entry) {
isAlreadyInstalled = isScriptInstalled(entry.name)
}
ElevatedCard(Modifier.padding(bottom = 8.dp).animateContentSize()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Code, null, Modifier.padding(end = 12.dp)
)
Column(
Modifier.weight(1f),
verticalArrangement = Arrangement.Center
) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.Bottom
) {
Text(
text = entry.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.Bold
)
entry.author?.let {
Text(
text = "by $it",
maxLines = 1,
fontSize = 10.sp,
fontWeight = FontWeight.Light,
overflow = TextOverflow.Ellipsis
)
}
}
entry.description?.let {
Text(
text = it,
fontSize = 12.sp,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
Text(
text = "Version: ${entry.version ?: "N/A"}",
fontWeight = FontWeight.Light,
fontSize = 11.sp
)
}
Button(
enabled = !isDownloading && !isAlreadyInstalled,
onClick = {
isDownloading = true
downloadScript(repoUrl, entry)
coroutineScope.launch {
delay(1000)
isDownloading = false
isAlreadyInstalled = isScriptInstalled(entry.name)
}
}
) {
when {
isAlreadyInstalled -> Text("Installed")
isDownloading -> CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
else -> Text("Download")
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,24 @@
# Snapenhance Script Repositories
## How to Add a Repository
1. Copy the script repo URL from here
2. Open SnapEnhance
3. Navigate to **Scripting** section
4. Go to the **Catalog** tab
5. Tap the **Manage Repos** button
6. Click **Add Repository**
7. Paste the repository URL
8. Click **Add**
**Then the scripts available from that repo will be listed in the Catalog Tab!**
## Available Script Repositories
#### 📦 SE-Scripts
- **URL**: `https://github.com/particle-box/SE-Scripts`
- **Maintainer**: ΞTΞRNAL
#### 📦 SE-Scripts-Bold
- **URL**: `https://github.com/sujalsxhu/SE-Scripts-Bold`
- **Maintainer**: Sujal Sahu

View File

@@ -1,9 +1,11 @@
package me.rhunk.snapenhance.ui.manager.pages.scripting
import android.content.Intent
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.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
@@ -12,6 +14,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.font.FontStyle
@@ -19,7 +22,6 @@ 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.navigation.NavBackStackEntry
import kotlinx.coroutines.*
import me.rhunk.snapenhance.common.scripting.type.ModuleInfo
import me.rhunk.snapenhance.common.scripting.ui.EnumScriptInterface
@@ -40,15 +42,48 @@ import me.rhunk.snapenhance.ui.util.chooseFolder
import me.rhunk.snapenhance.ui.util.pullrefresh.PullRefreshIndicator
import me.rhunk.snapenhance.ui.util.pullrefresh.pullRefresh
import me.rhunk.snapenhance.ui.util.pullrefresh.rememberPullRefreshState
import java.io.File
class ScriptingRootSection : Routes.Route() {
private lateinit var activityLauncherHelper: ActivityLauncherHelper
private val reloadDispatcher = AsyncUpdateDispatcher(updateOnFirstComposition = false)
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("Script already installed!")
return@launch
}
runCatching {
context.shortToast("Downloading script...")
val moduleInfo = context.scriptManager.importFromUrl(scriptUrl)
context.shortToast("Script ${moduleInfo.name} downloaded!")
reloadDispatcher.dispatch()
onComplete()
}.onFailure {
context.log.error("Failed to download script", it)
context.shortToast("Failed to download script. Check logs for more details")
}
}
}
@Composable
private fun ImportRemoteScript(
dismiss: () -> Unit
@@ -56,12 +91,9 @@ class ScriptingRootSection : Routes.Route() {
Dialog(onDismissRequest = dismiss) {
var url by remember { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
var isLoading by remember {
mutableStateOf(false)
}
var isLoading by remember { mutableStateOf(false) }
ElevatedCard(
modifier = Modifier
.fillMaxWidth(),
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
@@ -85,23 +117,15 @@ class ScriptingRootSection : Routes.Route() {
)
TextField(
value = url,
onValueChange = {
url = it
},
label = {
Text(text = "Enter URL here:")
},
onValueChange = { url = it },
label = { Text(text = "Enter URL here:") },
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.onGloballyPositioned {
focusRequester.requestFocus()
}
.onGloballyPositioned { focusRequester.requestFocus() }
)
LaunchedEffect(Unit) {
context.androidContext.getUrlFromClipboard()?.let {
url = it
}
context.androidContext.getUrlFromClipboard()?.let { url = it }
}
Spacer(modifier = Modifier.height(8.dp))
Button(
@@ -110,6 +134,14 @@ class ScriptingRootSection : Routes.Route() {
isLoading = true
context.coroutineScope.launch {
runCatching {
if (isScriptInstalledByUrl(url)) {
context.shortToast("Script already installed!")
withContext(Dispatchers.Main) {
dismiss()
}
return@launch
}
val moduleInfo = context.scriptManager.importFromUrl(url)
context.shortToast("Script ${moduleInfo.name} imported!")
reloadDispatcher.dispatch()
@@ -127,8 +159,7 @@ class ScriptingRootSection : Routes.Route() {
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier
.size(30.dp),
modifier = Modifier.size(30.dp),
strokeWidth = 3.dp,
color = MaterialTheme.colorScheme.onPrimary
)
@@ -141,21 +172,14 @@ class ScriptingRootSection : Routes.Route() {
}
}
@Composable
private fun ModuleActions(
script: ModuleInfo,
canUpdate: Boolean,
dismiss: () -> Unit
) {
Dialog(
onDismissRequest = dismiss,
) {
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.padding(2.dp),
) {
Dialog(onDismissRequest = dismiss) {
ElevatedCard(modifier = Modifier.fillMaxWidth().padding(2.dp)) {
val actions = remember {
mutableMapOf<Pair<String, ImageVector>, suspend () -> Unit>().apply {
if (canUpdate) {
@@ -177,16 +201,13 @@ class ScriptingRootSection : Routes.Route() {
}
}
}
put("Edit Module" 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
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()
@@ -197,8 +218,7 @@ class ScriptingRootSection : Routes.Route() {
}
put("Clear Module Data" to Icons.Default.Save) {
runCatching {
context.scriptManager.getModuleDataFolder(script.name)
.deleteRecursively()
context.scriptManager.getModuleDataFolder(script.name).deleteRecursively()
context.shortToast("Module data cleared!")
dismiss()
}.onFailure {
@@ -223,18 +243,13 @@ class ScriptingRootSection : Routes.Route() {
}
}.toMap()
}
LazyColumn(
modifier = Modifier.fillMaxWidth()
) {
LazyColumn(modifier = Modifier.fillMaxWidth()) {
item {
Text(
text = "Actions",
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
modifier = Modifier.padding(16.dp).fillMaxWidth(),
textAlign = TextAlign.Center,
)
}
@@ -242,22 +257,12 @@ class ScriptingRootSection : Routes.Route() {
val action = actions.entries.elementAt(index)
ListItem(
modifier = Modifier
.clickable {
context.coroutineScope.launch {
action.value()
dismiss()
}
}
.clickable { context.coroutineScope.launch { action.value(); dismiss() } }
.fillMaxWidth(),
leadingContent = {
Icon(
imageVector = action.key.second,
contentDescription = action.key.first
)
},
headlineContent = {
Text(text = action.key.first)
Icon(action.key.second, action.key.first)
},
headlineContent = { Text(action.key.first) }
)
}
}
@@ -282,26 +287,18 @@ class ScriptingRootSection : Routes.Route() {
LaunchedEffect(Unit) {
reloadDispatcher.addCallback(reloadCallback)
}
DisposableEffect(Unit) {
onDispose {
reloadDispatcher.removeCallback(reloadCallback)
}
onDispose { reloadDispatcher.removeCallback(reloadCallback) }
}
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
modifier = Modifier.fillMaxWidth().padding(8.dp),
elevation = CardDefaults.cardElevation()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
if (!enabled) return@clickable
openSettings = !openSettings
}
.clickable { if (enabled) openSettings = !openSettings }
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
@@ -309,27 +306,25 @@ class ScriptingRootSection : Routes.Route() {
Icon(
imageVector = if (openSettings) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = null,
modifier = Modifier
.padding(end = 8.dp)
.size(32.dp),
modifier = Modifier.padding(end = 8.dp).size(32.dp),
)
}
Column(
modifier = Modifier
.weight(1f)
.padding(end = 8.dp)
modifier = Modifier.weight(1f).padding(end = 8.dp)
) {
Text(text = script.displayName ?: script.name, fontSize = 20.sp)
Text(text = script.description ?: "No description", fontSize = 14.sp)
latestUpdate?.let {
Text(text = "Update available: ${it.version}", fontSize = 14.sp, fontStyle = FontStyle.Italic, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
text = "Update available: ${it.version}",
fontSize = 14.sp,
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
IconButton(onClick = {
openActions = !openActions
}) {
Icon(imageVector = Icons.Default.Build, contentDescription = "Actions")
IconButton(onClick = { openActions = !openActions }) {
Icon(Icons.Default.Build, "Actions")
}
Switch(
checked = enabled,
@@ -347,84 +342,80 @@ class ScriptingRootSection : Routes.Route() {
} else {
context.shortToast("Unloaded script ${script.name}")
}
context.database.setScriptEnabled(script.name, isChecked)
withContext(Dispatchers.Main) {
enabled = isChecked
}
withContext(Dispatchers.Main) { enabled = isChecked }
}.onFailure { throwable ->
withContext(Dispatchers.Main) {
enabled = !isChecked
}
("Failed to ${if (isChecked) "enable" else "disable"} script. Check logs for more details").also {
context.log.error(it, throwable)
context.shortToast(it)
}
withContext(Dispatchers.Main) { enabled = !isChecked }
context.log.error("Failed to ${if (isChecked) "enable" else "disable"} script", throwable)
context.shortToast("Failed to ${if (isChecked) "enable" else "disable"} script. Check logs for more details")
}
}
}
)
}
if (openSettings) {
ScriptSettings(script)
}
}
if (openActions) {
ModuleActions(
script = script,
canUpdate = latestUpdate != null,
) { openActions = false }
ModuleActions(script = script, canUpdate = latestUpdate != null) { openActions = false }
}
}
override val floatingActionButton: @Composable () -> Unit = {
var showImportDialog by remember {
mutableStateOf(false)
}
val tab = selectedTab
var showImportDialog by remember { mutableStateOf(false) }
var showToast by remember { mutableStateOf(false) }
val scriptingFolder = context.scriptManager.getScriptsFolder()
if (showImportDialog) {
ImportRemoteScript {
showImportDialog = false
ImportRemoteScript { showImportDialog = false }
}
if (showToast) {
LaunchedEffect(Unit) {
context.shortToast("Please select your scripts folder!")
showToast = false
}
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.End,
) {
ExtendedFloatingActionButton(
onClick = {
if (context.scriptManager.getScriptsFolder() == null) {
return@ExtendedFloatingActionButton
}
showImportDialog = true
},
icon = { Icon(imageVector = Icons.Default.Link, contentDescription = "Link") },
text = {
Text(text = "Import from URL")
},
)
ExtendedFloatingActionButton(
onClick = {
context.scriptManager.getScriptsFolder()?.let {
context.androidContext.openLink(it.uri.toString())
}
},
icon = {
Icon(
imageVector = Icons.Default.FolderOpen,
contentDescription = "Folder"
)
},
text = {
Text(text = "Open Scripts Folder")
},
)
if (tab == 1) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.End) {
ExtendedFloatingActionButton(
onClick = { routes.manageScriptRepos.navigate() },
icon = { Icon(Icons.Default.Public, contentDescription = null) },
text = { Text("Manage Repos") }
)
}
} else {
Column(verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.End) {
ExtendedFloatingActionButton(
onClick = {
if (scriptingFolder == null) {
showToast = true
} else {
showImportDialog = true
}
},
icon = { Icon(imageVector = Icons.Default.Link, contentDescription = "Link") },
text = { Text(text = "Import from URL") }
)
ExtendedFloatingActionButton(
onClick = {
if (scriptingFolder == null) {
showToast = true
} else {
scriptingFolder.let {
context.androidContext.openLink(it.uri.toString())
}
}
},
icon = { Icon(imageVector = Icons.Default.FolderOpen, contentDescription = "Folder") },
text = { Text(text = "Open Scripts Folder") }
)
}
}
}
@Composable
fun ScriptSettings(script: ModuleInfo) {
val settingsInterface = remember {
@@ -432,7 +423,6 @@ class ScriptingRootSection : Routes.Route() {
context.scriptManager.runtime.getModuleByName(script.name) ?: return@remember null
(module.getBinding(InterfaceManager::class))?.buildInterface(EnumScriptInterface.SETTINGS)
}
if (settingsInterface == null) {
Text(
text = "This module does not have any settings",
@@ -444,132 +434,172 @@ class ScriptingRootSection : Routes.Route() {
}
}
override val content: @Composable (NavBackStackEntry) -> Unit = {
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
val scriptingFolder by rememberAsyncMutableState(
defaultValue = null,
updateDispatcher = reloadDispatcher
) {
context.scriptManager.getScriptsFolder()
}
val scriptModules by rememberAsyncMutableState(
defaultValue = emptyList(),
updateDispatcher = reloadDispatcher
) {
context.scriptManager.sync()
context.scriptManager.getSyncedModules()
}
) { context.scriptManager.getScriptsFolder() }
val tab = selectedTab
val tabTitles = listOf("Installed Scripts", "Catalog")
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()
) {
LazyColumn(
Column(Modifier.fillMaxSize()) {
SingleChoiceSegmentedButtonRow(
modifier = Modifier
.fillMaxSize()
.pullRefresh(pullRefreshState),
horizontalAlignment = Alignment.CenterHorizontally
.fillMaxWidth()
.padding(horizontal = 8.dp)
) {
item {
if (scriptingFolder == null && !refreshing) {
Text(
text = "No scripts folder selected",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(8.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = {
activityLauncherHelper.chooseFolder {
context.config.root.scripting.moduleFolder.set(it)
context.config.writeConfig()
coroutineScope.launch {
reloadDispatcher.dispatch()
tabTitles.forEachIndexed { i, text ->
val shape = when (i) {
0 -> RoundedCornerShape(topStart = 24.dp, bottomStart = 24.dp)
tabTitles.lastIndex -> RoundedCornerShape(topEnd = 24.dp, bottomEnd = 24.dp)
else -> RoundedCornerShape(0.dp)
}
SegmentedButton(
selected = tab == i,
onClick = {
if (i == 1 && scriptingFolder == null) {
context.shortToast("Please select your scripts folder first!")
} else {
selectedTab = i
}
},
shape = shape,
modifier = Modifier.weight(1f),
icon = {},
label = { Text(text) }
)
}
}
when (tab) {
0 -> {
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()) {
LazyColumn(
modifier = Modifier.fillMaxSize().pullRefresh(pullRefreshState),
contentPadding = PaddingValues(bottom = routes.bottomPadding),
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
if (scriptingFolder == null && !refreshing) {
Box(
modifier = Modifier.fillMaxWidth().height(320.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "No scripts folder selected",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 16.dp)
)
Button(
onClick = {
activityLauncherHelper.chooseFolder {
context.config.root.scripting.moduleFolder.set(it)
context.config.writeConfig()
coroutineScope.launch { reloadDispatcher.dispatch() }
}
},
contentPadding = PaddingValues(horizontal = 28.dp, vertical = 10.dp)
) {
Text(
text = "Select folder",
fontSize = 18.sp
)
}
}
}
} else if (scriptModules.isEmpty()) {
Box(
modifier = Modifier.fillMaxWidth().height(320.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "No scripts found.",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "Use the catalog tab to add scripts!",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp)
)
}
}
}
}
}) {
Text(text = "Select folder")
items(scriptModules.size, key = { scriptModules[it].hashCode() }) { index ->
ModuleItem(scriptModules[index])
}
}
} else if (scriptModules.isEmpty()) {
Text(
text = "No scripts found",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(8.dp)
PullRefreshIndicator(
refreshing = refreshing,
state = pullRefreshState,
modifier = Modifier.align(Alignment.TopCenter)
)
}
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--
}
}
AlertDialog(onDismissRequest = {
if (timeout == 0) scriptingWarning = false
}, title = {
Text(text = context.translation["manager.dialogs.scripting_warning.title"])
}, text = {
Text(text = context.translation["manager.dialogs.scripting_warning.content"])
}, confirmButton = {
TextButton(
onClick = { scriptingWarning = false },
enabled = timeout == 0
) {
Text(text = "OK " + if (timeout > 0) "($timeout)" else "")
}
})
}
}
items(scriptModules.size, key = { scriptModules[it].hashCode() }) { index ->
ModuleItem(scriptModules[index])
}
item {
Spacer(modifier = Modifier.height(200.dp))
1 -> {
ScriptCatalog(this@ScriptingRootSection)
}
}
PullRefreshIndicator(
refreshing = refreshing,
state = pullRefreshState,
modifier = Modifier.align(Alignment.TopCenter)
)
}
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--
}
}
AlertDialog(onDismissRequest = {
if (timeout == 0) {
scriptingWarning = false
}
}, title = {
Text(text = context.translation["manager.dialogs.scripting_warning.title"])
}, text = {
Text(text = context.translation["manager.dialogs.scripting_warning.content"])
}, confirmButton = {
TextButton(
onClick = {
scriptingWarning = false
},
enabled = timeout == 0
) {
Text(text = "OK " + if (timeout > 0) "($timeout)" else "")
}
})
}
}
@@ -582,4 +612,4 @@ class ScriptingRootSection : Routes.Route() {
text = "Documentation",
)
}
}
}

View File

@@ -144,6 +144,7 @@ class ManageScope: Routes.Route() {
)
}
}
Spacer(modifier = Modifier.height(routes.bottomPadding))
}
}

View File

@@ -349,6 +349,7 @@ class MessagingPreview: Routes.Route() {
modifier = Modifier
.fillMaxWidth(),
state = previewScrollState,
contentPadding = PaddingValues(bottom = routes.bottomPadding)
) {
items(messages, key = { it.serverMessageId }) {message ->
val messageReader = remember(message.contentType) { ProtoReader(message.content) }

View File

@@ -32,7 +32,6 @@ 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.coil.BitmojiImage
import me.rhunk.snapenhance.ui.util.pagerTabIndicatorOffset
class SocialRootSection : Routes.Route() {
private var friendList: List<MessagingFriendInfo> by mutableStateOf(emptyList())
@@ -52,7 +51,7 @@ class SocialRootSection : Routes.Route() {
LazyColumn(
modifier = Modifier
.fillMaxSize(),
contentPadding = PaddingValues(top = 10.dp, bottom = 110.dp, start = 8.dp, end = 8.dp),
contentPadding = PaddingValues(start = 8.dp, end = 8.dp, bottom = routes.bottomPadding),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
//check if scope list is empty
@@ -182,13 +181,7 @@ class SocialRootSection : Routes.Route() {
}
}
@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 }
override val floatingActionButton: @Composable () -> Unit = {
var addFriendDialog by remember { mutableStateOf(null as AddFriendDialog?) }
if (addFriendDialog != null) {
@@ -202,87 +195,103 @@ class SocialRootSection : Routes.Route() {
}
}
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),
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
shape = RoundedCornerShape(16.dp),
) {
Icon(
imageVector = Icons.Rounded.Add,
contentDescription = null
)
}
}
@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()
}
Scaffold(
floatingActionButton = {
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),
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
shape = RoundedCornerShape(16.dp),
) {
Icon(
imageVector = Icons.Rounded.Add,
contentDescription = null
Column(modifier = Modifier.fillMaxSize()) {
SingleChoiceSegmentedButtonRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
) {
titles.forEachIndexed { index, title ->
val shape = when (index) {
0 -> RoundedCornerShape(topStart = 24.dp, bottomStart = 24.dp)
titles.lastIndex -> RoundedCornerShape(topEnd = 24.dp, bottomEnd = 24.dp)
else -> RoundedCornerShape(0.dp)
}
SegmentedButton(
selected = pagerState.currentPage == index,
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(index)
}
},
shape = shape,
modifier = Modifier.weight(1f),
icon = {},
label = {
Text(
text = title,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
)
}
}
) { paddingValues ->
Column(modifier = Modifier.padding(paddingValues)) {
TabRow(selectedTabIndex = pagerState.currentPage, indicator = { tabPositions ->
TabRowDefaults.SecondaryIndicator(
Modifier.pagerTabIndicatorOffset(
pagerState = pagerState,
tabPositions = tabPositions
)
)
}) {
titles.forEachIndexed { index, title ->
Tab(
selected = pagerState.currentPage == index,
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(index)
}
},
text = {
Text(
text = title,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
)
}
}
HorizontalPager(
modifier = Modifier.padding(paddingValues),
state = pagerState
) { page ->
when (page) {
0 -> ScopeList(SocialScope.FRIEND)
1 -> ScopeList(SocialScope.GROUP)
}
HorizontalPager(
modifier = Modifier.fillMaxSize(),
state = pagerState
) { page ->
when (page) {
0 -> ScopeList(SocialScope.FRIEND)
1 -> ScopeList(SocialScope.GROUP)
}
}
}
}
}
}

View File

@@ -1,149 +1,357 @@
@file:OptIn(
androidx.compose.material3.ExperimentalMaterial3Api::class,
androidx.compose.foundation.layout.ExperimentalLayoutApi::class
)
package me.rhunk.snapenhance.ui.manager.pages.tracker
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.ExperimentalFoundationApi
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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
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.Color
import androidx.compose.ui.text.TextStyle
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.rhunk.snapenhance.common.data.*
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList
import me.rhunk.snapenhance.storage.*
import me.rhunk.snapenhance.ui.manager.Routes
import me.rhunk.snapenhance.ui.manager.components.AestheticDialog
import me.rhunk.snapenhance.ui.manager.pages.social.AddFriendDialog
@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.size(30.dp),
checked = checked.value,
onCheckedChange = {
checked.value = it
onChanged(it)
}
)
Text(text, fontSize = 12.sp)
}
}
@Composable
fun ConditionCheckboxes(
params: TrackerRuleActionParams
) {
ActionCheckbox(text = "Only when I'm inside conversation", checked = remember { mutableStateOf(params.onlyInsideConversation) }, onChanged = { params.onlyInsideConversation = it })
ActionCheckbox(text = "Only when I'm outside conversation", checked = remember { mutableStateOf(params.onlyOutsideConversation) }, onChanged = { params.onlyOutsideConversation = it })
ActionCheckbox(text = "Only when Snapchat is active", checked = remember { mutableStateOf(params.onlyWhenAppActive) }, onChanged = { params.onlyWhenAppActive = it })
ActionCheckbox(text = "Only when Snapchat is inactive", checked = remember { mutableStateOf(params.onlyWhenAppInactive) }, onChanged = { params.onlyWhenAppInactive = it })
ActionCheckbox(text = "No notification when Snapchat is active", checked = remember { mutableStateOf(params.noPushNotificationWhenAppActive) }, onChanged = { params.noPushNotificationWhenAppActive = it })
}
class EditRule : Routes.Route() {
private val fab = mutableStateOf<@Composable (() -> Unit)?>(null)
// persistent add event state
private var currentEventType by mutableStateOf(TrackerEventType.CONVERSATION_ENTER.key)
private var addEventActions by mutableStateOf(emptySet<TrackerRuleAction>())
private val addEventActionParams by mutableStateOf(TrackerRuleActionParams())
override val floatingActionButton: @Composable () -> Unit = {
fab.value?.invoke()
@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) }
)
Text(text, fontSize = 12.sp)
}
}
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
override val content: @Composable (NavBackStackEntry) -> Unit = { navBackStackEntry ->
val currentRuleId = navBackStackEntry.arguments?.getString("rule_id")?.toIntOrNull()
val events = rememberAsyncMutableStateList(defaultValue = emptyList()) {
currentRuleId?.let { ruleId ->
context.database.getTrackerEvents(ruleId)
} ?: emptyList()
}
var currentScopeType by remember { mutableStateOf(TrackerScopeType.BLACKLIST) }
val scopes = rememberAsyncMutableStateList(defaultValue = emptyList()) {
currentRuleId?.let { ruleId ->
context.database.getRuleTrackerScopes(ruleId).also {
currentScopeType = if (it.isEmpty()) {
TrackerScopeType.WHITELIST
} else {
it.values.first()
@Composable
fun ConditionCheckboxes(
params: TrackerRuleActionParams
) {
ActionCheckbox(
text = "Only when I'm inside conversation",
checked = remember { mutableStateOf(params.onlyInsideConversation) },
onChanged = { params.onlyInsideConversation = it }
)
ActionCheckbox(
text = "Only when I'm outside conversation",
checked = remember { mutableStateOf(params.onlyOutsideConversation) },
onChanged = { params.onlyOutsideConversation = it }
)
ActionCheckbox(
text = "Only when Snapchat is active",
checked = remember { mutableStateOf(params.onlyWhenAppActive) },
onChanged = { params.onlyWhenAppActive = it }
)
ActionCheckbox(
text = "Only when Snapchat is inactive",
checked = remember { mutableStateOf(params.onlyWhenAppInactive) },
onChanged = { params.onlyWhenAppInactive = it }
)
ActionCheckbox(
text = "No notification when Snapchat is 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)
) {
Card {
Column(
Modifier
.padding(16.dp)
.width(IntrinsicSize.Max)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Add Event", style = MaterialTheme.typography.titleLarge, modifier = Modifier.weight(1f))
IconButton(onClick = onDismissRequest) {
Icon(Icons.Default.DeleteOutline, contentDescription = "Close")
}
}
}.map { it.key }
} ?: emptyList()
}
val ruleName = rememberAsyncMutableState(defaultValue = "", keys = arrayOf(currentRuleId)) {
currentRuleId?.let { ruleId ->
context.database.getTrackerRule(ruleId)?.name ?: "Custom Rule"
} ?: "Custom Rule"
}
LaunchedEffect(Unit) {
fab.value = {
var deleteConfirmation by remember { mutableStateOf(false) }
if (deleteConfirmation) {
AlertDialog(
onDismissRequest = { deleteConfirmation = false },
title = { Text("Delete Rule") },
text = { Text("Are you sure you want to delete this rule?") },
confirmButton = {
Button(
onClick = {
if (currentRuleId != null) {
context.database.deleteTrackerRule(currentRuleId)
}
routes.navController.popBackStack()
}
) {
Text("Delete")
}
},
dismissButton = {
Button(
onClick = { deleteConfirmation = false }
) {
Text("Cancel")
Spacer(Modifier.height(16.dp))
ExposedDropdownMenuBox(
expanded = expanded.value,
onExpandedChange = { expanded.value = !expanded.value },
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = context.translation["tracker_events.${currentEventType.value}"],
onValueChange = {},
readOnly = true,
label = { Text("Event type") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded.value) },
)
ExposedDropdownMenu(
expanded = expanded.value,
onDismissRequest = { expanded.value = false },
modifier = Modifier.width(IntrinsicSize.Max)
) {
TrackerEventType.entries.forEach { eventType ->
DropdownMenuItem(
onClick = {
currentEventType.value = eventType.key
expanded.value = false
},
text = { Text(context.translation["tracker_events.${eventType.key}"]) }
)
}
}
)
}
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(6.dp),
horizontalAlignment = Alignment.End
) {
ExtendedFloatingActionButton(
}
Spacer(Modifier.height(12.dp))
Text("Triggers", style = MaterialTheme.typography.titleMedium)
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
}
}
}
Spacer(Modifier.height(12.dp))
Text("Conditions", style = MaterialTheme.typography.titleMedium)
ConditionCheckboxes(addEventActionParams)
Spacer(Modifier.height(16.dp))
Button(
onClick = {
onEventAdd(
TrackerRuleEvent(
id = -1,
enabled = true,
eventType = currentEventType.value,
params = addEventActionParams.copy(),
actions = addEventActions.value.toList()
)
)
},
modifier = Modifier.align(Alignment.End)
) { Text("Add") }
}
}
}
}
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 ?: "Custom Rule" } ?: "Custom Rule"
}
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 = "Discard Changes?",
text = "You have unsaved changes. Are you sure you want to discard them?",
icon = Icons.Default.Warning,
confirmButtonText = "Discard",
onConfirm = {
showDiscardDialog = false
routes.navController.popBackStack()
},
dismissButtonText = "Cancel",
onDismiss = { showDiscardDialog = false }
)
}
BackHandler(enabled = isDirty) {
showDiscardDialog = true
}
if (showEventsEmptyDialog) {
Dialog(onDismissRequest = { showEventsEmptyDialog = false }) {
Card(
shape = RoundedCornerShape(16.dp),
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Icon(
imageVector = Icons.Default.Info,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = "Cannot Save Rule",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Text(
text = "A rule must have at least one event to save.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center
)
Button(
onClick = { showEventsEmptyDialog = false },
modifier = Modifier.fillMaxWidth()
) {
Text("OK")
}
}
}
}
}
if (showDuplicateNameDialog) {
AlertDialog(
onDismissRequest = { showDuplicateNameDialog = false },
title = { Text("Duplicate Rule Name") },
text = { Text("A rule with this name already exists. Please choose a different name.") },
confirmButton = {
Button(onClick = { showDuplicateNameDialog = false }) {
Text("OK")
}
}
)
}
if (deleteConfirmation) {
AlertDialog(
onDismissRequest = { deleteConfirmation = false },
title = { Text("Delete Rule") },
text = { Text("Are you sure you want to delete this rule?") },
confirmButton = {
Button(
onClick = {
if (currentRuleId != null) context.database.deleteTrackerRule(currentRuleId)
routes.navController.popBackStack()
}
) { Text("Delete") }
},
dismissButton = {
Button(onClick = { deleteConfirmation = false }) { Text("Cancel") }
}
)
}
Scaffold(
modifier = Modifier
.fillMaxSize()
.systemBarsPadding()
.navigationBarsPadding(),
topBar = {
TopAppBar(
title = { Text(if (currentRuleId == null) "New Rule" else "Edit Rule") },
navigationIcon = {
IconButton(onClick = {
if (isDirty) {
showDiscardDialog = true
} else {
routes.navController.popBackStack()
}
}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
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 ->
if (event.id > -1) context.database.deleteTrackerRuleEvent(event.id)
}
events.forEach { event ->
context.database.addOrUpdateTrackerRuleEvent(
event.id.takeIf { it > -1 },
@@ -154,303 +362,204 @@ class EditRule : Routes.Route() {
)
}
context.database.setTrackerRuleName(ruleId, ruleName.value.trim())
context.database.setTrackerRuleAuthor(ruleId, authorName.value.trim())
context.database.setRuleTrackerScopes(ruleId, currentScopeType, scopes)
routes.navController.popBackStack()
},
text = { Text("Save Rule") },
icon = { Icon(Icons.Default.Save, contentDescription = "Save Rule") }
)
if (currentRuleId != null) {
ExtendedFloatingActionButton(
containerColor = MaterialTheme.colorScheme.error,
onClick = { deleteConfirmation = true },
text = { Text("Delete Rule") },
icon = { Icon(Icons.Default.DeleteOutline, contentDescription = "Delete Rule") }
)
}
}
}
}
DisposableEffect(Unit) {
onDispose { fab.value = null }
}
LazyColumn(
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
item {
TextField(
value = ruleName.value,
onValueChange = {
ruleName.value = it
},
singleLine = true,
placeholder = {
Text(
"Rule Name",
fontSize = 18.sp,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
},
modifier = Modifier.fillMaxWidth(),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent
),
textStyle = TextStyle(fontSize = 20.sp, textAlign = TextAlign.Center, fontWeight = FontWeight.Bold)
)
}
item {
Column(
modifier = Modifier.fillMaxWidth(),
){
Text("Scope", fontSize = 16.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(16.dp))
var addFriendDialog by remember { mutableStateOf(null as AddFriendDialog?) }
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
}
)
}
Box(modifier = Modifier.clickable { scopes.clear() }) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = scopes.isEmpty(), onClick = null)
Text("All Friends/Groups")
}
}
Box(modifier = Modifier.clickable {
currentScopeType = TrackerScopeType.WHITELIST
addFriendDialog = AddFriendDialog(
context,
friendDialogActions,
pinnedIds = scopes,
)
}) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = scopes.isNotEmpty() && currentScopeType == TrackerScopeType.WHITELIST, onClick = null)
Text("No one except " + if (currentScopeType == TrackerScopeType.WHITELIST && scopes.isNotEmpty()) scopes.size.toString() + " friends/groups" else "...")
}
}
Box(modifier = Modifier.clickable {
currentScopeType = TrackerScopeType.BLACKLIST
addFriendDialog = AddFriendDialog(
context,
friendDialogActions,
pinnedIds = scopes,
)
}) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = scopes.isNotEmpty() && currentScopeType == TrackerScopeType.BLACKLIST, onClick = null)
Text("Everyone except " + if (currentScopeType == TrackerScopeType.BLACKLIST && scopes.isNotEmpty()) scopes.size.toString() + " friends/groups" else "...")
}
}
addFriendDialog?.Content {
addFriendDialog = null
}
}
var addEventDialog by remember { mutableStateOf(false) }
val showDropdown = remember { mutableStateOf(false) }
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Events", fontSize = 16.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(16.dp))
IconButton(onClick = { addEventDialog = true }, modifier = Modifier.padding(8.dp)) {
Icon(Icons.Default.Add, contentDescription = "Add Event", modifier = Modifier.size(32.dp))
}
}
if (addEventDialog) {
AlertDialog(
onDismissRequest = { addEventDialog = false },
title = { Text("Add Event", fontSize = 20.sp, fontWeight = FontWeight.Bold) },
text = {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Type", fontSize = 14.sp, fontWeight = FontWeight.Bold)
ExposedDropdownMenuBox(expanded = showDropdown.value, onExpandedChange = { showDropdown.value = it }) {
ElevatedButton(
onClick = { showDropdown.value = true },
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable)
) {
Text(context.translation["tracker_events.$currentEventType"], overflow = TextOverflow.Ellipsis, maxLines = 1)
}
DropdownMenu(expanded = showDropdown.value, onDismissRequest = { showDropdown.value = false }) {
TrackerEventType.entries.forEach { eventType ->
DropdownMenuItem(onClick = {
currentEventType = eventType.key
showDropdown.value = false
}, text = {
Text(context.translation["tracker_events.${eventType.key}"])
})
}
}
}
}
Text("Triggers", fontSize = 14.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(2.dp))
FlowRow(
modifier = Modifier
.fillMaxWidth()
.padding(2.dp),
) {
TrackerRuleAction.entries.forEach { action ->
ActionCheckbox(context.translation["tracker_actions.${action.key}"], checked = remember { mutableStateOf(addEventActions.contains(action)) }) {
if (it) {
addEventActions += action
} else {
addEventActions -= action
}
}
}
}
Text("Conditions", fontSize = 14.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(2.dp))
ConditionCheckboxes(addEventActionParams)
}
},
confirmButton = {
Button(
onClick = {
events.add(0, TrackerRuleEvent(-1, true, currentEventType, addEventActionParams.copy(), addEventActions.toList()))
addEventDialog = false
}
) {
Text("Add")
}
}
)
}
}
item {
if (events.isEmpty()) {
Text("No events", fontSize = 12.sp, fontWeight = FontWeight.Light, modifier = Modifier
.padding(10.dp)
.fillMaxWidth(), textAlign = TextAlign.Center)
}
}
items(events) { event ->
var expanded by remember { mutableStateOf(false) }
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.padding(4.dp),
onClick = { expanded = !expanded }
) {
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier.weight(1f, fill = false),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
Column {
Text(context.translation["tracker_events.${event.eventType}"], lineHeight = 20.sp, fontSize = 18.sp, fontWeight = FontWeight.Bold)
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)
}
}
OutlinedIconButton(
onClick = {
if (event.id > -1) {
context.database.deleteTrackerRuleEvent(event.id)
}
events.remove(event)
}
) {
}) { Icon(Icons.Filled.Save, contentDescription = "Save") }
if (currentRuleId != null) {
IconButton(onClick = { deleteConfirmation = true }) {
Icon(Icons.Default.DeleteOutline, contentDescription = "Delete")
}
}
if (expanded) {
Column(
modifier = Modifier.padding(10.dp)
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Card(Modifier.fillMaxWidth().padding(12.dp)) {
Column(Modifier.padding(16.dp)) {
Text("General", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = ruleName.value,
onValueChange = { ruleName.value = it },
label = { Text("Rule Name") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = authorName.value,
onValueChange = { authorName.value = it },
label = { Text("Author Name") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
}
}
Card(Modifier.fillMaxWidth().padding(horizontal = 12.dp)) {
Column(Modifier.padding(16.dp)) {
Text("Scope", style = MaterialTheme.typography.titleMedium)
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("All", "Whitelist", "Blacklist")
val selectedScopeIndex = when {
scopes.isEmpty() -> 0
currentScopeType == TrackerScopeType.WHITELIST -> 1
else -> 2
}
SingleChoiceSegmentedButtonRow(
modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 12.dp)
) {
scopeOptions.forEachIndexed { index, label ->
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(index, scopeOptions.size),
onClick = {
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)
}
}
}
},
selected = index == selectedScopeIndex
) { Text(label) }
}
}
if (scopes.isNotEmpty()) {
Button(
onClick = {
addFriendDialog = AddFriendDialog(context, friendDialogActions, pinnedIds = scopes)
},
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)
) { Text("Select Friends/Groups (${scopes.size})") }
}
addFriendDialog?.Content { addFriendDialog = null }
}
}
Card(Modifier.fillMaxWidth().padding(12.dp)) {
Column(
Modifier
.padding(16.dp)
.animateContentSize()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Events", fontSize = 16.sp, fontWeight = FontWeight.Bold)
IconButton(onClick = { addEventDialogVisible = true }) {
Icon(Icons.Default.Add, contentDescription = "Add Event", modifier = Modifier.size(28.dp))
}
}
if (addEventDialogVisible) {
AddEventDialog(
onDismissRequest = { addEventDialogVisible = false },
onEventAdd = { event ->
events.add(0, event)
addEventDialogVisible = false
}
)
}
if (events.isEmpty()) {
Text(
"No events",
fontSize = 12.sp,
fontWeight = FontWeight.Light,
modifier = Modifier
.padding(10.dp)
.fillMaxWidth(),
textAlign = TextAlign.Center
)
}
events.forEach { event ->
var expanded by remember { mutableStateOf(false) }
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.animateContentSize()
.padding(vertical = 4.dp)
.clickable { expanded = !expanded }
) {
ConditionCheckboxes(event.params)
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
)
Column {
Text(
context.translation["tracker_events.${event.eventType}"],
lineHeight = 20.sp,
fontSize = 18.sp,
fontWeight = FontWeight.Bold
)
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
)
}
}
OutlinedIconButton(
onClick = {
if (event.id > -1) {
eventsToDelete.add(event)
}
events.remove(event)
}
) {
Icon(Icons.Default.DeleteOutline, contentDescription = "Delete")
}
}
if (expanded) {
Column(modifier = Modifier.padding(top = 8.dp)) {
ConditionCheckboxes(event.params)
}
}
}
}
}
}
}
}
item {
Spacer(modifier = Modifier.height(140.dp))
Spacer(Modifier.height(routes.bottomPadding))
}
}
}
}
}

View File

@@ -0,0 +1,259 @@
@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
package me.rhunk.snapenhance.ui.manager.pages.tracker
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
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.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material.icons.filled.Info
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.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.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 okhttp3.OkHttpClient
import okhttp3.Request
data class FriendTrackerRepoManifest(
val rules: List<FriendTrackerRepoEntry>
)
data class FriendTrackerRepoEntry(
val name: String,
val author: String? = null,
val description: String? = null,
val path: String
)
@OptIn(ExperimentalMaterial3Api::class)
class FriendTrackerCatalog : Routes.Route() {
@Composable
private fun AvailableRulesTab() {
val coroutineScope = rememberCoroutineScope()
val okHttpClient = remember { OkHttpClient() }
val gson = remember { context.gson }
var repositories by remember { mutableStateOf<List<String>>(emptyList()) }
var repoIndexes by remember { mutableStateOf<Map<String, FriendTrackerRepoManifest>>(emptyMap()) }
var isLoading by remember { mutableStateOf(false) }
// Ticks whenever a rule import happens so isImported values recompute
var importTick by remember { mutableStateOf(0) }
fun refreshIndexes() {
coroutineScope.launch(Dispatchers.IO) {
isLoading = true
val repos = context.database.getRepositories("friend_tracker")
withContext(Dispatchers.Main) {
repositories = repos
}
if (repos.isNotEmpty()) {
val newIndexes = mutableMapOf<String, FriendTrackerRepoManifest>()
repos.forEach { repoRoot ->
val indexUrl = if (repoRoot.endsWith("/")) "${repoRoot}index.json" else "$repoRoot/index.json"
try {
val req = Request.Builder().url(indexUrl).build()
okHttpClient.newCall(req).execute().use { response ->
if (response.isSuccessful) {
response.body?.charStream()?.let { reader ->
val parsed = gson.fromJson(reader, FriendTrackerRepoManifest::class.java)
if (parsed.rules.isNotEmpty()) {
newIndexes[repoRoot] = parsed
}
}
}
}
} catch (_: Exception) {}
}
withContext(Dispatchers.Main) {
repoIndexes = newIndexes
isLoading = false
}
} else {
withContext(Dispatchers.Main) {
isLoading = false
}
}
}
}
LaunchedEffect(Unit) {
refreshIndexes()
routes.onRuleImported = {
importTick++
}
}
val allRules = repoIndexes.entries.flatMap { (repoUrl, manifest) ->
manifest.rules.map { repoUrl to it }
}
fun importRule(repoUrl: String, entry: FriendTrackerRepoEntry) {
coroutineScope.launch(Dispatchers.IO) {
val rawUrl = if (repoUrl.endsWith("/")) repoUrl + entry.path else repoUrl + "/" + entry.path
try {
val req = Request.Builder().url(rawUrl).build()
okHttpClient.newCall(req).execute().use { response ->
if (!response.isSuccessful) {
withContext(Dispatchers.Main) { context.shortToast("Failed download: ${response.code}") }
return@use
}
val content = response.body?.string()
if (content != null) {
withContext(Dispatchers.Main) {
routes.friendTrackerConfigJsonForImport = content
routes.friendTrackerConfigImport.navigate()
}
}
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
context.shortToast("Error: ${e.localizedMessage}")
}
}
}
}
if (repositories.isEmpty() && !isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "No repositories added.",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface
)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 8.dp, top = 8.dp, end = 8.dp, bottom = 8.dp + routes.bottomPadding)
) {
item {
if (isLoading) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else if (allRules.isEmpty() && repositories.isNotEmpty()) {
Text(
text = "No rules available from any repo.",
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
textAlign = TextAlign.Center,
fontSize = 15.sp,
fontWeight = FontWeight.Light,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
items(allRules) { (repoUrl, entry) ->
// Compute isImported using produceState so the suspend db call runs in a coroutine
val isImported by produceState(initialValue = false, key1 = entry.name, key2 = importTick) {
val exists = withContext(Dispatchers.IO) {
context.database.getTrackerRuleByName(entry.name) != null
}
value = exists
}
ElevatedCard(Modifier.padding(bottom = 8.dp).animateContentSize()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.AutoMirrored.Filled.Rule, null, Modifier.padding(end = 12.dp)
)
Column(
Modifier.weight(1f),
verticalArrangement = Arrangement.Center
) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.Bottom
) {
Text(
text = entry.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.Bold
)
entry.author?.let {
Text(
text = "by $it",
maxLines = 1,
fontSize = 10.sp,
fontWeight = FontWeight.Light,
overflow = TextOverflow.Ellipsis
)
}
}
entry.description?.let {
Text(
text = it,
fontSize = 12.sp,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
}
Button(
onClick = {
importRule(repoUrl, entry)
},
enabled = !isImported
) {
Text(if (isImported) "Imported" else "Import")
}
}
}
}
}
}
}
override val title: @Composable () -> Unit = { Text("Friend Tracker Catalog") }
override val topBarActions: @Composable RowScope.() -> Unit = {
IconButton(onClick = { routes.manageFriendTrackerRepos.navigate() }) {
Icon(Icons.Default.Public, contentDescription = "Manage Repositories")
}
}
override val content: @Composable (NavBackStackEntry) -> Unit = {
AvailableRulesTab()
}
}

View File

@@ -0,0 +1,168 @@
package me.rhunk.snapenhance.ui.manager.pages.tracker
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
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.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.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 org.json.JSONArray
class FriendTrackerConfigExportScreen : Routes.Route() {
@OptIn(ExperimentalMaterial3Api::class)
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = { navBackStackEntry ->
val ruleId = navBackStackEntry.arguments?.getString("rule_id")?.toIntOrNull()
val parser = remember { TrackerConfigParser(this) }
var trackerData by remember { mutableStateOf<ExportedTrackerData?>(null) }
var featuresByCategory by remember { mutableStateOf<Map<String, List<ImportedFeature>>>(emptyMap()) }
LaunchedEffect(Unit) {
launch(Dispatchers.IO) {
val data = if (ruleId != null) {
context.trackerDataManager.getExportedTrackerData(ruleId)
} else {
context.trackerDataManager.getExportedTrackerData()
}
trackerData = data
featuresByCategory = data?.let { parser.parse(context.gson.toJson(it)) } ?: emptyMap()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Export Rules") },
navigationIcon = {
IconButton(onClick = { routes.navController.popBackStack() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
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("Friend Tracker Rules Exported!")
}
}
}.onFailure {
context.longToast("Failed to export rules: ${it.message}")
}
}
}) {
Text("Save")
}
}
)
}
) { padding ->
LazyColumn(
modifier = Modifier.padding(padding).fillMaxSize(),
contentPadding = PaddingValues(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 16.dp + routes.bottomPadding),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(featuresByCategory.toList()) { (category, features) ->
var isExpanded by remember { mutableStateOf(ruleId != null) }
val rotationState by animateFloatAsState(targetValue = if (isExpanded) 180f else 0f, label = "rotationState")
Card(
modifier = Modifier.fillMaxWidth().clickable { isExpanded = !isExpanded },
shape = RoundedCornerShape(16.dp)
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = category,
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
modifier = Modifier.weight(1f)
)
IconButton(onClick = { isExpanded = !isExpanded }) {
Icon(
imageVector = Icons.Default.KeyboardArrowDown,
contentDescription = "Expand",
modifier = Modifier.graphicsLayer(rotationZ = rotationState)
)
}
}
AnimatedVisibility(visible = isExpanded) {
Column {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
features.forEach { feature ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.padding(start = (feature.indentation * 16).dp),
verticalAlignment = Alignment.Top
) {
Text(
text = feature.name,
modifier = Modifier.weight(1f),
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = parser.parseValue(feature.key, feature.value).toString(),
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.End,
)
}
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
}
}
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,153 @@
package me.rhunk.snapenhance.ui.manager.pages.tracker
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.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.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.launch
import me.rhunk.snapenhance.common.data.ExportedTrackerData
import me.rhunk.snapenhance.ui.manager.Routes
import org.json.JSONArray
class FriendTrackerConfigImportScreen : Routes.Route() {
@OptIn(ExperimentalMaterial3Api::class)
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
val configJson = routes.friendTrackerConfigJsonForImport ?: ""
val parser = remember { TrackerConfigParser(this) }
val featuresByCategory = remember {
parser.parse(configJson)
}
val expandedState = remember { mutableStateMapOf<String, Boolean>() }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Import Rules") },
navigationIcon = {
IconButton(onClick = { routes.navController.popBackStack() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
TextButton(onClick = {
runCatching {
val trackerData = context.gson.fromJson(configJson, ExportedTrackerData::class.java)
context.trackerDataManager.importTrackerData(trackerData)
}.onSuccess {
context.shortToast("Friend Tracker Rules Imported!")
routes.onRuleImported?.invoke()
routes.navController.popBackStack()
}.onFailure {
context.longToast("Failed to import rules: ${it.message}")
}
}) {
Text("Confirm")
}
}
)
}
) { padding ->
val trackerData = remember { context.gson.fromJson(configJson, ExportedTrackerData::class.java) }
val isSingleRule = remember { trackerData.rules.size == 1 }
LazyColumn(
modifier = Modifier.padding(padding).fillMaxSize(),
contentPadding = PaddingValues(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 16.dp + routes.bottomPadding),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(featuresByCategory.toList()) { (category, features) ->
var isExpanded by remember { mutableStateOf(isSingleRule) }
val rotationState by animateFloatAsState(targetValue = if (isExpanded) 180f else 0f, label = "rotationState")
Card(
modifier = Modifier.fillMaxWidth().clickable { isExpanded = !isExpanded },
shape = RoundedCornerShape(16.dp)
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = category,
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
modifier = Modifier.weight(1f)
)
IconButton(onClick = { isExpanded = !isExpanded }) {
Icon(
imageVector = Icons.Default.KeyboardArrowDown,
contentDescription = "Expand",
modifier = Modifier.graphicsLayer(rotationZ = rotationState)
)
}
}
AnimatedVisibility(visible = isExpanded) {
Column {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
features.forEach { feature ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.padding(start = (feature.indentation * 16).dp),
verticalAlignment = Alignment.Top
) {
Text(
text = feature.name,
modifier = Modifier.weight(1f),
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = parser.parseValue(feature.key, feature.value).toString(),
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.End,
)
}
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
}
}
}
}
}
}
}
}
}
}

View File

@@ -13,13 +13,23 @@ 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.DeleteOutline
import androidx.compose.material.icons.filled.FolderOpen
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.foundation.layout.padding
import androidx.compose.ui.unit.dp
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
@@ -41,6 +51,7 @@ import me.rhunk.snapenhance.storage.*
import me.rhunk.snapenhance.ui.manager.Routes
import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper
import me.rhunk.snapenhance.ui.util.coil.BitmojiImage
import me.rhunk.snapenhance.ui.util.openFile
import me.rhunk.snapenhance.ui.util.pagerTabIndicatorOffset
@@ -50,11 +61,115 @@ class FriendTrackerManagerRoot : Routes.Route() {
CONVERSATION, USERNAME, EVENT
}
private val titles = listOf("Logs", "Rules")
private val titles = listOf("Rules", "Logs")
private var currentPage by mutableIntStateOf(0)
private lateinit var logDeleteAction : () -> Unit
private lateinit var exportAction : () -> Unit
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) {
ChoiceDialog(
onDismissRequest = { showExportDialog = false },
title = "Export",
choices = listOf(
"Bulk Export" to { Icon(Icons.Default.UploadFile, null) },
"Individual Export" to { Icon(Icons.Default.FileOpen, null) }
),
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()
}
}
)
}
fun handleImport(type: me.rhunk.snapenhance.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.rhunk.snapenhance.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("Invalid Import Type") },
text = { Text("The selected file is not compatible with this import type. Please select the correct import type.") },
confirmButton = {
Button(onClick = { showInvalidImportTypeDialog = false }) {
Text("OK")
}
}
)
}
if (showImportDialog) {
ChoiceDialog(
onDismissRequest = { showImportDialog = false },
title = "Import",
choices = listOf(
"Bulk Import" to { Icon(Icons.Default.UploadFile, null) },
"Individual Import" to { Icon(Icons.Default.FileOpen, null) }
),
onChoiceSelected = { index ->
showImportDialog = false
when (index) {
0 -> handleImport(me.rhunk.snapenhance.common.data.ExportType.BULK)
1 -> handleImport(me.rhunk.snapenhance.common.data.ExportType.SINGLE)
}
}
)
}
if (currentPage == 0) {
IconButton(onClick = {
showImportDialog = true
}) {
Icon(Icons.Default.FolderOpen, contentDescription = "Import")
}
IconButton(onClick = {
showExportDialog = true
}) {
Icon(Icons.Default.SaveAlt, contentDescription = "Export")
}
}
}
private lateinit var activityLauncherHelper: ActivityLauncherHelper
override val init: () -> Unit = {
@@ -63,7 +178,7 @@ class FriendTrackerManagerRoot : Routes.Route() {
override val floatingActionButton: @Composable () -> Unit = {
when (currentPage) {
0 -> {
1 -> {
Column(
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
@@ -85,13 +200,21 @@ class FriendTrackerManagerRoot : Routes.Route() {
)
}
}
1 -> {
ExtendedFloatingActionButton(
icon = { Icon(Icons.Default.Add, contentDescription = "Add Rule") },
expanded = true,
text = { Text("Add Rule") },
onClick = { routes.editRule.navigate() }
)
0 -> {
Column(verticalArrangement = Arrangement.spacedBy(6.dp), horizontalAlignment = Alignment.End) {
ExtendedFloatingActionButton(
icon = { Icon(Icons.Default.Store, contentDescription = "Catalog") },
expanded = true,
text = { Text("Catalog") },
onClick = { routes.friendTrackerCatalog.navigate() }
)
ExtendedFloatingActionButton(
icon = { Icon(Icons.Default.Add, contentDescription = "Add Rule") },
expanded = true,
text = { Text("Add Rule") },
onClick = { routes.editRule.navigate() }
)
}
}
}
}
@@ -107,7 +230,8 @@ class FriendTrackerManagerRoot : Routes.Route() {
modifier = Modifier.fillMaxSize()
) {
LazyColumn(
modifier = Modifier.weight(1f)
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(bottom = routes.bottomPadding)
) {
item {
if (rules.isEmpty()) {
@@ -227,33 +351,32 @@ class FriendTrackerManagerRoot : Routes.Route() {
@OptIn(ExperimentalFoundationApi::class)
override val content: @Composable (NavBackStackEntry) -> Unit = {
val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState { titles.size }
val pagerState = rememberPagerState(initialPage = 0) { titles.size }
currentPage = pagerState.currentPage
Column {
TabRow(selectedTabIndex = pagerState.currentPage, indicator = { tabPositions ->
TabRowDefaults.SecondaryIndicator(
Modifier.pagerTabIndicatorOffset(
pagerState = pagerState,
tabPositions = tabPositions
)
)
}) {
titles.forEachIndexed { index, title ->
Tab(
selected = pagerState.currentPage == index,
SingleChoiceSegmentedButtonRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
) {
titles.forEachIndexed { i, text ->
val shape = when (i) {
0 -> RoundedCornerShape(topStart = 24.dp, bottomStart = 24.dp)
titles.lastIndex -> RoundedCornerShape(topEnd = 24.dp, bottomEnd = 24.dp)
else -> RoundedCornerShape(0.dp)
}
SegmentedButton(
selected = pagerState.currentPage == i,
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(index)
pagerState.animateScrollToPage(i)
}
},
text = {
Text(
text = title,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
shape = shape,
modifier = Modifier.weight(1f),
icon = {},
label = { Text(text) }
)
}
}
@@ -263,15 +386,108 @@ class FriendTrackerManagerRoot : Routes.Route() {
state = pagerState
) { page ->
when (page) {
0 -> LogsTab(
1 -> LogsTab(
context = context,
activityLauncherHelper = activityLauncherHelper,
deleteAction = { logDeleteAction = it },
exportAction = { exportAction = it }
exportAction = { exportAction = it },
bottomPadding = routes.bottomPadding
)
1 -> ConfigRulesTab()
0 -> ConfigRulesTab()
}
}
}
}
}
}
@Composable
private fun SelectRuleDialog(
onDismissRequest: () -> Unit,
rules: List<me.rhunk.snapenhance.common.data.TrackerRule>,
onRuleSelected: (me.rhunk.snapenhance.common.data.TrackerRule) -> Unit
) {
Dialog(onDismissRequest = onDismissRequest) {
Card(
shape = RoundedCornerShape(16.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text("Select Rule to Export", 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("Cancel")
}
}
}
}
}
@Composable
private fun ChoiceDialog(
onDismissRequest: () -> Unit,
title: String,
choices: List<Pair<String, @Composable () -> Unit>>,
onChoiceSelected: (Int) -> Unit
) {
Dialog(onDismissRequest = onDismissRequest) {
Card(
shape = RoundedCornerShape(16.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(text = title, style = MaterialTheme.typography.headlineSmall)
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

@@ -0,0 +1,37 @@
# 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.
## Repository Structure
A repository is a simple collection of files hosted on a web server or a Git repository. The root of the repository must contain an `index.json` file.
### `index.json` Format
The `index.json` file contains a list of all the rules in the repository. It is a JSON array of objects, where each object represents a rule.
Here is an example of an `index.json` file:
```json
{
"rules": [
{
"name": "Example Rule",
"author": "Your Name",
"description": "This is an example rule that does something.",
"path": "rules/example_rule.json"
}
]
}
```
#### Rule Object Properties
* `name` (String, required): The name of the rule. This will be displayed in the catalog.
* `author` (String, optional): The author of the rule.
* `description` (String, optional): A short description of what the rule does.
* `path` (String, required): The relative path to the rule's JSON file from the root of the repository.
### 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.

View File

@@ -18,6 +18,7 @@ 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.unit.Dp
import androidx.compose.ui.window.PopupProperties
import com.google.gson.stream.JsonWriter
import kotlinx.coroutines.Dispatchers
@@ -44,6 +45,7 @@ fun LogsTab(
activityLauncherHelper: ActivityLauncherHelper,
deleteAction: (() -> Unit) -> Unit,
exportAction: (() -> Unit) -> Unit,
bottomPadding: Dp,
) {
val coroutineScope = rememberCoroutineScope()
@@ -516,7 +518,8 @@ fun LogsTab(
}
LazyColumn(
modifier = Modifier.weight(1f)
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(bottom = bottomPadding)
) {
item {
Row(
@@ -592,9 +595,6 @@ fun LogsTab(
}
}
item {
Spacer(modifier = Modifier.height(100.dp))
}
}
}
}

View File

@@ -0,0 +1,238 @@
package me.rhunk.snapenhance.ui.manager.pages.tracker
import androidx.compose.ui.Alignment
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Public
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.material.icons.filled.Error
import androidx.core.net.toUri
import com.google.gson.JsonParser
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import me.rhunk.snapenhance.common.util.ktx.getUrlFromClipboard
import me.rhunk.snapenhance.storage.addRepo
import me.rhunk.snapenhance.storage.getRepositories
import me.rhunk.snapenhance.storage.removeRepo
import me.rhunk.snapenhance.ui.manager.Routes
import me.rhunk.snapenhance.ui.manager.components.AestheticDialog
import okhttp3.OkHttpClient
class ManageFriendTrackerReposSection: Routes.Route() {
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 = "Invalid Repository",
text = errorDialogMessage,
icon = Icons.Default.Error,
confirmButtonText = "OK",
onConfirm = { showErrorDialog = false }
)
}
ExtendedFloatingActionButton(onClick = { showAddDialog = true }) {
Text("Add Repository")
}
if (showAddDialog) {
val coroutineScope = rememberCoroutineScope { Dispatchers.IO }
var url by remember { mutableStateOf("") }
var loading by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = { showAddDialog = false },
title = { Text("Add Repository URL") },
text = {
val focusRequester = remember { FocusRequester() }
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.onGloballyPositioned { focusRequester.requestFocus() },
value = url,
onValueChange = { url = it },
label = { Text("Repository URL") }
)
LaunchedEffect(Unit) {
context.androidContext.getUrlFromClipboard()?.let { url = it }
}
},
confirmButton = {
Button(
enabled = !loading && url.isNotBlank(),
onClick = {
loading = true
coroutineScope.launch {
runCatching {
var modifiedUrl = url
if (url.startsWith("https://github.com/")) {
val splitUrl = modifiedUrl.removePrefix("https://github.com/").split("/")
val repoName = splitUrl[0] + "/" + splitUrl[1]
okHttpClient.newCall(
okhttp3.Request.Builder().url("https://api.github.com/repos/$repoName").build()
).execute().use { response ->
if (!response.isSuccessful) {
throw Exception("Failed to fetch default branch: ${response.code}")
}
val json = response.body?.string() ?: throw Exception("Empty response")
val defaultBranch = Regex("\"default_branch\":\"([^\"]+)\"").find(json)?.groupValues?.get(1)
?: throw Exception("No default_branch field")
modifiedUrl = "https://raw.githubusercontent.com/$repoName/$defaultBranch/"
}
}
val indexUrl = modifiedUrl.toUri().buildUpon().appendPath("index.json").build().toString()
val request = okhttp3.Request.Builder().url(indexUrl).build()
val isValid = okHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw Exception("Failed to fetch index.json: ${response.code}")
val indexJson = response.body?.string() ?: throw Exception("Empty index.json")
JsonParser.parseString(indexJson).asJsonObject.has("rules")
}
if (isValid) {
context.database.addRepo("friend_tracker", modifiedUrl)
context.shortToast("Repository added successfully!")
showAddDialog = false
refreshTrigger.value++
} else {
errorDialogMessage = "This does not appear to be a valid Friend Tracker repository."
showErrorDialog = true
}
}.onFailure {
context.log.error("Failed to add repository", it)
context.shortToast("Failed to add repository: ${it.message}")
}
loading = false
}
}
) {
if (loading) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
} else {
Text("Add")
}
}
}
)
}
}
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
val repositories by remember(refreshTrigger.value) {
mutableStateOf<List<String>>(runBlocking { context.database.getRepositories("friend_tracker") })
}
if (repositories.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "No repositories added",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface
)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 8.dp, top = 8.dp, end = 8.dp, bottom = 8.dp + routes.bottomPadding),
) {
items(repositories) { url ->
val (repoName, author) = remember(url) {
url.removePrefix("https://raw.githubusercontent.com/").split("/").let { it[1] to it[0] }
}
ElevatedCard(
modifier = Modifier.padding(bottom = 8.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Public,
contentDescription = null,
modifier = Modifier.size(40.dp),
tint = MaterialTheme.colorScheme.primary
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = repoName,
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp
)
Text(
text = author,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
var showRemoveDialog by remember { mutableStateOf(false) }
Button(
onClick = { showRemoveDialog = true }
) {
Text("Remove")
}
AnimatedVisibility(visible = showRemoveDialog) {
AlertDialog(
onDismissRequest = { showRemoveDialog = false },
title = { Text("Remove Repository") },
text = { Text("Are you sure you want to remove this repository?") },
confirmButton = {
Button(
onClick = {
context.database.removeRepo("friend_tracker", url)
showRemoveDialog = false
refreshTrigger.value++
}
) {
Text("Remove")
}
},
dismissButton = {
Button(onClick = { showRemoveDialog = false }) {
Text("Cancel")
}
}
)
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,44 @@
package me.rhunk.snapenhance.ui.manager.pages.tracker
import me.rhunk.snapenhance.common.data.ExportedTrackerData
import me.rhunk.snapenhance.ui.manager.Routes
import org.json.JSONArray
data class ImportedFeature(
val category: String,
val name: String,
val key: String,
val value: Any,
val indentation: Int
)
class TrackerConfigParser(private val context: Routes.Route) {
fun parse(configJson: String): Map<String, List<ImportedFeature>> {
val featureMap = mutableMapOf<String, MutableList<ImportedFeature>>()
val exportedData = context.context.gson.fromJson(configJson, ExportedTrackerData::class.java)
exportedData.rules.forEach { rule ->
val features = mutableListOf<ImportedFeature>()
features.add(ImportedFeature(rule.name, "Author", "author", rule.author ?: "Unknown", 0))
features.add(ImportedFeature(rule.name, "Enabled", "enabled", rule.enabled, 0))
rule.events?.forEach { event ->
features.add(ImportedFeature(rule.name, context.context.translation["tracker_events.${event.eventType}"], event.eventType, event.actions.joinToString(", ") { context.context.translation["tracker_actions.${it.key}"] }, 1))
}
featureMap[rule.name] = features
}
return featureMap
}
fun parseValue(featureKey: String, value: Any): Any {
return when (value) {
is Boolean -> if (value) "Enabled" else "Disabled"
is JSONArray -> {
val list = mutableListOf<String>()
for (i in 0 until value.length()) {
list.add(value.get(i).toString())
}
list
}
else -> value.toString()
}
}
}

View File

@@ -1,3 +1,4 @@
@file:OptIn(androidx.compose.animation.ExperimentalAnimationApi::class)
package me.rhunk.snapenhance.ui.overlay
import android.app.Dialog
@@ -60,7 +61,7 @@ class RemoteOverlay(
containerColor = MaterialTheme.colorScheme.background,
topBar = { navigation.TopBar() }
) { innerPadding ->
navigation.Content(
navigation.NavContent(
innerPadding,
startDestination = remember { startRoute(navigation.routes).routeInfo.id }
)
@@ -123,4 +124,5 @@ class RemoteOverlay(
dialog.show()
}
}
}
}

View File

@@ -1,6 +1,8 @@
@file:OptIn(androidx.compose.animation.ExperimentalAnimationApi::class)
package me.rhunk.snapenhance.ui.setup
import android.annotation.SuppressLint
import android.app.Activity
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
@@ -14,7 +16,7 @@ import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -22,10 +24,24 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.navigationBars
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import me.rhunk.snapenhance.ui.util.scaleOnPress
import me.rhunk.snapenhance.SharedContextHolder
import me.rhunk.snapenhance.common.ui.AppMaterialTheme
import me.rhunk.snapenhance.ui.setup.screens.SetupScreen
@@ -34,27 +50,19 @@ import me.rhunk.snapenhance.ui.setup.screens.impl.PermissionsScreen
import me.rhunk.snapenhance.ui.setup.screens.impl.PickLanguageScreen
import me.rhunk.snapenhance.ui.setup.screens.impl.SaveFolderScreen
class SetupActivity : ComponentActivity() {
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val setupContext = SharedContextHolder.remote(this).apply {
activity = this@SetupActivity
}
fun endActivity() {
setupContext.reload()
finish()
}
val requirements = intent.getIntExtra("requirements", Requirements.FIRST_RUN)
fun hasRequirement(requirement: Int) = requirements and requirement == requirement
val requiredScreens = mutableListOf<SetupScreen>()
with(requiredScreens) {
val isFirstRun = hasRequirement(Requirements.FIRST_RUN)
if (isFirstRun || hasRequirement(Requirements.LANGUAGE)) {
@@ -71,73 +79,62 @@ class SetupActivity : ComponentActivity() {
}
}
// If there are no required screens, we can just finish the activity
if (requiredScreens.isEmpty()) {
endActivity()
return
}
requiredScreens.forEach { screen ->
screen.context = setupContext
screen.init()
}
setContent {
val navController = rememberNavController()
var canGoNext by remember { mutableStateOf(false) }
fun nextScreen() {
if (!canGoNext) return
requiredScreens.firstOrNull()?.onLeave()
if (requiredScreens.size > 1) {
canGoNext = false
requiredScreens.removeFirst()
requiredScreens.removeAt(0)
navController.navigate(requiredScreens.first().route)
} else {
endActivity()
}
}
AppMaterialTheme {
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
bottomBar = {
Column(
modifier = Modifier
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
val alpha: Float by animateFloatAsState(if (canGoNext) 1f else 0f,
label = "NextButton"
)
FilledIconButton(
onClick = { nextScreen() },
modifier = Modifier.padding(50.dp)
.width(60.dp)
.height(60.dp)
.alpha(alpha)
) {
Icon(
imageVector = if (requiredScreens.size <= 1 && canGoNext) {
Icons.Default.Check
} else {
Icons.AutoMirrored.Default.ArrowForwardIos
},
contentDescription = null
)
}
}
},
val background = MaterialTheme.colorScheme.background
val isLight = background.luminance() > 0.5f
val view = LocalView.current
@Suppress("DEPRECATION")
SideEffect {
val window = (view.context as Activity).window
WindowCompat.setDecorFitsSystemWindows(window, false)
// Set transparent system bars and light/dark icons
window.statusBarColor = Color.Transparent.toArgb()
window.navigationBarColor = Color.Transparent.toArgb()
val insetsController = WindowInsetsControllerCompat(window, window.decorView)
insetsController.isAppearanceLightStatusBars = isLight
insetsController.isAppearanceLightNavigationBars = isLight
}
Box(
modifier = Modifier
.fillMaxSize()
.background(background)
) {
val bottomPadding = 110.dp +
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
Column(
modifier = Modifier
.background(MaterialTheme.colorScheme.background)
.fillMaxSize()
.padding(bottom = bottomPadding)
) {
NavHost(
navController = navController,
startDestination = requiredScreens.first().route
startDestination = requiredScreens.first().route,
enterTransition = { fadeIn() },
exitTransition = { fadeOut() },
popEnterTransition = { fadeIn() },
popExitTransition = { fadeOut() }
) {
requiredScreens.forEach { screen ->
screen.allowNext = { canGoNext = it }
@@ -145,7 +142,13 @@ class SetupActivity : ComponentActivity() {
canGoNext = true
nextScreen()
}
composable(screen.route) {
composable(
screen.route,
enterTransition = { slideInHorizontally { it } },
exitTransition = { slideOutHorizontally { -it } },
popEnterTransition = { slideInHorizontally { -it } },
popExitTransition = { slideOutHorizontally { it } }
) {
BackHandler(true) {}
Column(
modifier = Modifier.fillMaxSize(),
@@ -158,8 +161,32 @@ class SetupActivity : ComponentActivity() {
}
}
}
val alpha: Float by animateFloatAsState(if (canGoNext) 1f else 0f,
label = "NextButton"
)
val nextSrc = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }
FilledIconButton(
onClick = { nextScreen() },
modifier = Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.padding(bottom = 50.dp)
.size(60.dp)
.alpha(alpha)
.scaleOnPress(nextSrc),
interactionSource = nextSrc
) {
Icon(
imageVector = if (requiredScreens.size <= 1 && canGoNext) {
Icons.Default.Check
} else {
Icons.AutoMirrored.Filled.ArrowForwardIos
},
contentDescription = null
)
}
}
}
}
}
}
}

View File

@@ -1,6 +1,7 @@
package me.rhunk.snapenhance.ui.setup.screens
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@@ -21,6 +22,7 @@ abstract class SetupScreen {
text = text,
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(16.dp).then(modifier)
)
}
@@ -30,4 +32,4 @@ abstract class SetupScreen {
@Composable
abstract fun Content()
}
}

View File

@@ -8,11 +8,17 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.rhunk.snapenhance.ui.setup.screens.SetupScreen
import me.rhunk.snapenhance.ui.util.AlertDialogs
import me.rhunk.snapenhance.ui.util.Motion
class MappingsScreen : SetupScreen() {
@Composable
@@ -27,9 +33,17 @@ class MappingsScreen : SetupScreen() {
goNext()
}
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { visible = true }
Dialog(onDismissRequest = { dismiss() }) {
remember { AlertDialogs(context.translation) }.InfoDialog(title = infoText!!) {
dismiss()
AnimatedVisibility(
visible = visible,
enter = fadeIn(animationSpec = Motion.tweenFloatSpec(180)) + scaleIn(animationSpec = Motion.tweenFloatSpec(200)),
exit = fadeOut(animationSpec = Motion.tweenFloatSpec(150)) + scaleOut(animationSpec = Motion.tweenFloatSpec(180))
) {
remember { AlertDialogs(context.translation) }.InfoDialog(title = infoText!!) {
dismiss()
}
}
}
}
@@ -63,15 +77,17 @@ class MappingsScreen : SetupScreen() {
}
}
if (isGenerating) {
DialogText(text = context.translation["setup.mappings.dialog"])
CircularProgressIndicator(
modifier = Modifier
.padding()
.size(50.dp),
strokeWidth = 3.dp,
color = MaterialTheme.colorScheme.onPrimary
)
AnimatedVisibility(visible = isGenerating, enter = fadeIn(), exit = fadeOut()) {
androidx.compose.foundation.layout.Column(horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally) {
DialogText(text = context.translation["setup.mappings.dialog"])
CircularProgressIndicator(
modifier = Modifier
.padding()
.size(50.dp),
strokeWidth = 3.dp,
color = MaterialTheme.colorScheme.onPrimary
)
}
}
}
}
}

View File

@@ -22,6 +22,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.animation.core.tween
import androidx.compose.foundation.interaction.MutableInteractionSource
import me.rhunk.snapenhance.ui.util.scaleOnPress
import me.rhunk.snapenhance.ui.util.Motion
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.rhunk.snapenhance.ui.setup.screens.SetupScreen
@@ -43,7 +51,8 @@ class PermissionsScreen : SetupScreen() {
@Composable
private fun RequestButton(onClick: () -> Unit) {
Button(onClick = onClick) {
val src = remember { MutableInteractionSource() }
Button(onClick = onClick, interactionSource = src, modifier = Modifier.scaleOnPress(src)) {
Text(text = context.translation["setup.permissions.request_button"])
}
}
@@ -165,14 +174,22 @@ class PermissionsScreen : SetupScreen() {
text = context.translation["setup.permissions.${perm.translationKey}"],
modifier = Modifier.weight(1f)
)
if (grantedPermissions[perm.translationKey] == true) {
GrantedIcon()
} else {
RequestButton {
if (perm.isPermissionGranted()) {
grantedPermissions[perm.translationKey] = true
} else {
perm.requestPermission(perm)
val granted = grantedPermissions[perm.translationKey] == true
AnimatedContent(
targetState = granted,
transitionSpec = {
fadeIn(animationSpec = tween(150)) togetherWith fadeOut(animationSpec = tween(150))
}, label = "permState"
) { isGranted ->
if (isGranted) {
GrantedIcon()
} else {
RequestButton {
if (perm.isPermissionGranted()) {
grantedPermissions[perm.translationKey] = true
} else {
perm.requestPermission(perm)
}
}
}
}
@@ -181,4 +198,4 @@ class PermissionsScreen : SetupScreen() {
}
}
}
}
}

View File

@@ -1,5 +1,10 @@
package me.rhunk.snapenhance.ui.setup.screens.impl
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.clickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.scrollable
@@ -28,27 +33,28 @@ import androidx.compose.ui.window.Dialog
import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper
import me.rhunk.snapenhance.ui.setup.screens.SetupScreen
import me.rhunk.snapenhance.ui.util.ObservableMutableState
import me.rhunk.snapenhance.ui.util.Motion
import me.rhunk.snapenhance.ui.util.scaleOnPress
import androidx.compose.foundation.interaction.MutableInteractionSource
import java.util.Locale
class PickLanguageScreen : SetupScreen(){
class PickLanguageScreen : SetupScreen() {
private val availableLocales by lazy {
LocaleWrapper.fetchAvailableLocales(context.androidContext)
}
private lateinit var selectedLocale: ObservableMutableState<String>
private fun getLocaleDisplayName(locale: String): String {
locale.split("_").let {
if (it.size != 2) return Locale(locale).getDisplayName(Locale.getDefault())
return Locale(it[0], it[1]).getDisplayName(Locale.getDefault())
// Use Locale.forLanguageTag for all cases
val displayLocale = try {
Locale.forLanguageTag(locale.replace('_', '-'))
} catch (e: Exception) {
Locale.getDefault()
}
return displayLocale.getDisplayName(Locale.getDefault())
}
private fun reloadTranslation(selectedLocale: String) {
context.translation.reload(selectedLocale)
}
private fun setLocale(locale: String) {
with(context) {
config.locale = locale
@@ -56,78 +62,84 @@ class PickLanguageScreen : SetupScreen(){
reloadTranslation(locale)
}
}
override fun onLeave() {
context.config.locale = selectedLocale.value
context.config.writeConfig()
}
override fun init() {
val deviceLocale = Locale.getDefault().toString()
selectedLocale =
ObservableMutableState(
defaultValue = availableLocales.firstOrNull {
locale -> locale == deviceLocale
} ?: LocaleWrapper.DEFAULT_LOCALE
defaultValue = availableLocales.firstOrNull { locale -> locale == deviceLocale }
?: LocaleWrapper.DEFAULT_LOCALE
) { _, newValue ->
setLocale(newValue)
}.also { reloadTranslation(it.value) }
}
@Composable
override fun Content() {
allowNext(true)
DialogText(text = context.translation["setup.dialogs.select_language"])
var isDialog by remember { mutableStateOf(false) }
if (isDialog) {
var visible by remember { mutableStateOf(false) }
androidx.compose.runtime.LaunchedEffect(Unit) { visible = true }
Dialog(onDismissRequest = { isDialog = false }) {
Surface(
modifier = Modifier
.padding(10.dp)
.fillMaxWidth(),
shape = MaterialTheme.shapes.medium
AnimatedVisibility(
visible = visible,
enter = fadeIn(animationSpec = Motion.tweenFloatSpec(180)) + scaleIn(animationSpec = Motion.tweenFloatSpec(200)),
exit = fadeOut(animationSpec = Motion.tweenFloatSpec(150)) + scaleOut(animationSpec = Motion.tweenFloatSpec(180))
) {
LazyColumn(
modifier = Modifier.scrollable(rememberScrollState(), orientation = Orientation.Vertical)
Surface(
modifier = Modifier
.padding(10.dp)
.fillMaxWidth(),
shape = MaterialTheme.shapes.medium
) {
items(availableLocales) { locale ->
Box(
modifier = Modifier
.height(70.dp)
.fillMaxWidth()
.clickable {
selectedLocale.value = locale
isDialog = false
},
contentAlignment = Alignment.Center
) {
Text(
text = remember(locale) { getLocaleDisplayName(locale) },
fontSize = 16.sp,
fontWeight = FontWeight.Light,
)
LazyColumn(
modifier = Modifier.scrollable(rememberScrollState(), orientation = Orientation.Vertical)
) {
items(availableLocales) { locale ->
Box(
modifier = Modifier
.height(70.dp)
.fillMaxWidth()
.clickable {
selectedLocale.value = locale
isDialog = false
},
contentAlignment = Alignment.Center
) {
Text(
text = remember(locale) { getLocaleDisplayName(locale) },
fontSize = 16.sp,
fontWeight = FontWeight.Light,
)
}
}
}
}
}
}
}
Box(
modifier = Modifier
.padding(top = 40.dp)
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Button(onClick = {
isDialog = true
}) {
Text(text = remember(selectedLocale.value) { getLocaleDisplayName(selectedLocale.value) }, fontSize = 16.sp,
fontWeight = FontWeight.Normal)
val btnSrc = remember { MutableInteractionSource() }
Button(
onClick = { isDialog = true },
interactionSource = btnSrc,
modifier = Modifier.scaleOnPress(btnSrc)
) {
Text(
text = remember(selectedLocale.value) { getLocaleDisplayName(selectedLocale.value) },
fontSize = 16.sp,
fontWeight = FontWeight.Normal
)
}
}
}
}
}

View File

@@ -5,11 +5,14 @@ import androidx.compose.foundation.layout.height
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import me.rhunk.snapenhance.ui.setup.screens.SetupScreen
import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper
import me.rhunk.snapenhance.ui.util.chooseFolder
import androidx.compose.foundation.interaction.MutableInteractionSource
import me.rhunk.snapenhance.ui.util.scaleOnPress
class SaveFolderScreen : SetupScreen() {
private lateinit var activityLauncherHelper: ActivityLauncherHelper
@@ -22,6 +25,7 @@ class SaveFolderScreen : SetupScreen() {
override fun Content() {
DialogText(text = context.translation["setup.dialogs.save_folder"])
Spacer(modifier = Modifier.height(16.dp))
val src = remember { MutableInteractionSource() }
Button(onClick = {
activityLauncherHelper.chooseFolder {
if (it.isBlank()) return@chooseFolder
@@ -29,8 +33,8 @@ class SaveFolderScreen : SetupScreen() {
context.config.writeConfig()
goNext()
}
}) {
}, interactionSource = src, modifier = Modifier.scaleOnPress(src)) {
Text(text = context.translation["setup.dialogs.select_save_folder_button"])
}
}
}
}

View File

@@ -58,10 +58,9 @@ import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Overlay
import java.io.File
class AlertDialogs(
private val translation: LocaleWrapper,
){
) {
@Composable
fun DefaultDialogCard(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
Card(
@@ -159,11 +158,9 @@ class AlertDialogs(
val keys = (property.value.defaultValues as List<String>).toMutableList().apply {
add(0, "null")
}
var selectedValue by remember {
mutableStateOf(property.value.getNullable()?.toString() ?: "null")
}
DefaultDialogCard {
keys.forEachIndexed { index, item ->
fun select() {
@@ -174,7 +171,6 @@ class AlertDialogs(
item
})
}
Row(
modifier = Modifier.clickable { select() },
verticalAlignment = Alignment.CenterVertically
@@ -197,7 +193,6 @@ class AlertDialogs(
fun KeyboardInputDialog(property: PropertyPair<*>, dismiss: () -> Unit = {}) {
val focusRequester = remember { FocusRequester() }
val context = LocalContext.current
DefaultDialogCard {
var fieldValue by remember {
mutableStateOf(property.value.get().toString().let {
@@ -207,7 +202,6 @@ class AlertDialogs(
)
})
}
TextField(
modifier = Modifier
.fillMaxWidth()
@@ -225,7 +219,6 @@ class AlertDialogs(
},
singleLine = true
)
Row(
modifier = Modifier
.padding(top = 10.dp)
@@ -240,7 +233,6 @@ class AlertDialogs(
Toast.makeText(context, "Invalid input! Make sure you entered a valid value.", Toast.LENGTH_SHORT).show() //TODO: i18n
return@Button
}
when (property.key.dataType.type) {
DataProcessors.Type.INTEGER -> {
runCatching {
@@ -269,12 +261,10 @@ class AlertDialogs(
@Composable
fun RawInputDialog(onDismiss: () -> Unit, onConfirm: (value: String) -> Unit) {
val focusRequester = remember { FocusRequester() }
DefaultDialogCard {
val fieldValue = remember {
mutableStateOf(TextFieldValue())
}
TextField(
modifier = Modifier
.fillMaxWidth()
@@ -289,7 +279,6 @@ class AlertDialogs(
},
singleLine = true
)
Row(
modifier = Modifier
.padding(top = 10.dp)
@@ -316,7 +305,6 @@ class AlertDialogs(
DefaultDialogCard {
defaultItems.forEach { key ->
var state by remember { mutableStateOf(toggledStates.contains(key)) }
fun toggle(value: Boolean? = null) {
state = value ?: !state
if (state) {
@@ -325,7 +313,6 @@ class AlertDialogs(
toggledStates.remove(key)
}
}
Row(
modifier = Modifier.clickable { toggle() },
verticalAlignment = Alignment.CenterVertically
@@ -353,19 +340,12 @@ class AlertDialogs(
setProperty: (Color?) -> Unit,
dismiss: () -> Unit
) {
var currentColor by remember { mutableStateOf(initialColor) }
var currentColor by remember { mutableStateOf(initialColor ?: Color.White.copy(alpha = 1f)) }
DefaultDialogCard {
val controller = remember { ColorPickerController().apply {
if (currentColor == null) {
setWheelAlpha(1f)
setBrightness(1f, false)
}
} }
val controller = remember { ColorPickerController() }
var colorHexValue by remember {
mutableStateOf(currentColor?.toArgb()?.let { Integer.toHexString(it) } ?: "")
}
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
@@ -380,7 +360,7 @@ class AlertDialogs(
setProperty(it)
}
}.onFailure {
currentColor = null
currentColor = Color.White
}
},
label = { Text(text = "Hex Color") },
@@ -438,7 +418,7 @@ class AlertDialogs(
controller = controller
)
IconButton(onClick = {
setProperty(null)
setProperty(Color.White)
dismiss()
}) {
Icon(
@@ -459,7 +439,6 @@ class AlertDialogs(
var currentColor by remember {
mutableStateOf((property.value.getNullable() as? Int)?.let { Color(it) })
}
ColorPickerDialog(
initialColor = currentColor,
setProperty = setProperty@{
@@ -487,7 +466,6 @@ class AlertDialogs(
}
}
val context = LocalContext.current
mapView.value = remember {
Configuration.getInstance().apply {
osmdroidBasePath = File(context.cacheDir, "osmdroid")
@@ -497,17 +475,14 @@ class AlertDialogs(
setMultiTouchControls(true)
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
setTileSource(TileSourceFactory.MAPNIK)
val startPoint = GeoPoint(coordinates.first, coordinates.second)
controller.setZoom(10.0)
controller.setCenter(startPoint)
marker.value = Marker(this).apply {
isDraggable = true
position = startPoint
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
}
overlays.add(object: Overlay() {
override fun onSingleTapConfirmed(e: MotionEvent, mapView: MapView): Boolean {
marker.value?.position = mapView.projection.fromPixels(e.x.toInt(), e.y.toInt()) as GeoPoint
@@ -515,23 +490,17 @@ class AlertDialogs(
return true
}
})
overlays.add(marker.value)
}
}
DisposableEffect(Unit) {
onDispose {
mapView.value?.onDetach()
}
}
var customCoordinatesDialog by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope { Dispatchers.IO }
val okHttpClient by lazy { OkHttpClient() }
Box(
modifier = Modifier
.fillMaxWidth()
@@ -543,13 +512,12 @@ class AlertDialogs(
)
Column(
modifier = Modifier
.align(Alignment.TopCenter)
.align(Alignment.TopCenter)
.fillMaxWidth()
) {
var locationName by remember { mutableStateOf<String>("") }
var addressResults by remember { mutableStateOf<List<Triple<String, String, String>>>(emptyList()) }
var searchJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
suspend fun search() {
okHttpClient.newCall(Request.Builder()
.url("https://nominatim.openstreetmap.org/search".toUri().buildUpon().appendQueryParameter("q", locationName).appendQueryParameter("format", "jsonv2").build().toString())
@@ -559,9 +527,8 @@ class AlertDialogs(
if (!response.isSuccessful) {
return@use
}
runCatching {
val body = JsonParser.parseString(response.body.string()).asJsonArray
val body = JsonParser.parseString(response.body?.string()).asJsonArray
addressResults = body.take(5).map { jsonElement ->
val jsonObject = jsonElement.asJsonObject
Triple(
@@ -572,10 +539,8 @@ class AlertDialogs(
}
}
}
searchJob = null
}
TextField(
modifier = Modifier
.fillMaxWidth(),
@@ -598,14 +563,12 @@ class AlertDialogs(
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.None)
)
AutoClearKeyboardFocus(onFocusClear = {
locationName = ""
addressResults = emptyList()
searchJob?.cancel()
searchJob = null
})
Column(
modifier = Modifier
.fillMaxWidth()
@@ -613,38 +576,36 @@ class AlertDialogs(
.verticalScroll(ScrollState(0)),
) {
if (addressResults.isNotEmpty()) {
addressResults.forEach { address ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
marker.value?.position = GeoPoint(address.second.toDouble(), address.third.toDouble())
mapView.value?.controller?.setCenter(marker.value?.position)
mapView.value?.invalidate()
}
) {
Text(
text = address.first,
modifier = Modifier
.padding(10.dp)
.fillMaxWidth(),
)
}
}
} else {
if (searchJob?.isActive == true) {
addressResults.forEach { address ->
Row(
modifier = Modifier.fillMaxWidth().padding(10.dp),
horizontalArrangement = Arrangement.Center
modifier = Modifier
.fillMaxWidth()
.clickable {
marker.value?.position = GeoPoint(address.second.toDouble(), address.third.toDouble())
mapView.value?.controller?.setCenter(marker.value?.position)
mapView.value?.invalidate()
}
) {
CircularProgressIndicator()
Text(
text = address.first,
modifier = Modifier
.padding(10.dp)
.fillMaxWidth(),
)
}
}
} else if (searchJob?.isActive == true) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp),
horizontalArrangement = Arrangement.Center
) {
CircularProgressIndicator()
}
}
}
}
Row(
modifier = Modifier
.align(Alignment.BottomEnd)
@@ -678,7 +639,6 @@ class AlertDialogs(
)
}
}
FilledIconButton(
onClick = {
customCoordinatesDialog = true
@@ -692,11 +652,9 @@ class AlertDialogs(
)
}
}
if (customCoordinatesDialog) {
val lat = remember { mutableStateOf(coordinates.first.toString()) }
val lon = remember { mutableStateOf(coordinates.second.toString()) }
Dialog(onDismissRequest = {
customCoordinatesDialog = false
}) {
@@ -730,7 +688,6 @@ class AlertDialogs(
}) {
Text(text = translation["button.cancel"])
}
Button(onClick = {
marker.value?.position = GeoPoint(lat.value.toDouble(), lon.value.toDouble())
mapView.value?.controller?.setCenter(marker.value?.position)

View File

@@ -0,0 +1,92 @@
package me.rhunk.snapenhance.ui.util
import android.content.Context
import android.provider.Settings
import androidx.compose.animation.core.Easing
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
/**
* Returns true if the user has disabled animator duration scale at the system level.
* This respects Accessibility/Developer settings where motion is reduced or disabled.
*/
fun prefersReducedMotion(context: Context): Boolean {
return runCatching {
val scale = Settings.Global.getFloat(
context.contentResolver,
Settings.Global.ANIMATOR_DURATION_SCALE,
1f
)
scale == 0f
}.getOrDefault(false)
}
@Composable
fun rememberPrefersReducedMotion(): Boolean {
val context = androidx.compose.ui.platform.LocalContext.current
val state = remember { mutableStateOf(prefersReducedMotion(context)) }
// One-shot read; if you need to observe changes live, add a ContentObserver.
LaunchedEffect(Unit) {
state.value = prefersReducedMotion(context)
}
return state.value
}
object Motion {
@Composable
fun tweenSpec(durationMillis: Int, easing: Easing = androidx.compose.animation.core.FastOutSlowInEasing): FiniteAnimationSpec<Int> {
val reduced = rememberPrefersReducedMotion()
val d = if (reduced) 0 else durationMillis
return tween<Int>(durationMillis = d, easing = easing)
}
@Composable
fun tweenFloatSpec(durationMillis: Int, easing: Easing = androidx.compose.animation.core.FastOutSlowInEasing): FiniteAnimationSpec<Float> {
val reduced = rememberPrefersReducedMotion()
val d = if (reduced) 0 else durationMillis
return tween<Float>(durationMillis = d, easing = easing)
}
@Composable
fun springDefault(): FiniteAnimationSpec<Float> {
return spring(stiffness = Spring.StiffnessMedium)
}
}
/**
* Apply a subtle scale-down on press for clickable components (cards, buttons, tiles).
* Pass the same [interactionSource] into the clickable component to synchronize state.
*/
@Composable
fun Modifier.scaleOnPress(
interactionSource: InteractionSource,
enabled: Boolean = true,
scaleDown: Float = 0.98f
): Modifier {
val pressed by interactionSource.collectIsPressedAsState()
val target = if (pressed) scaleDown else 1f
val spec = Motion.tweenFloatSpec(150)
val animated by androidx.compose.animation.core.animateFloatAsState(
targetValue = target,
animationSpec = spec,
label = "pressScale"
)
return this.then(Modifier.graphicsLayer {
scaleX = animated
scaleY = animated
})
}

View File

@@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" xmlns:aapt="http://schemas.android.com/aapt"
android:viewportWidth="960"
android:viewportHeight="960"
android:width="24dp"
android:height="24dp">
<group
android:translateX="-0"
android:translateY="960">
<path
android:pathData="M370 -80l-16 -128q-13 -5 -24.5 -12T307 -235l-119 50L78 -375l103 -78q-1 -7 -1 -13.5v-27q0 -6.5 1 -13.5L78 -585l110 -190 119 50q11 -8 23 -15t24 -12l16 -128h220l16 128q13 5 24.5 12t22.5 15l119 -50 110 190 -103 78q1 7 1 13.5v27q0 6.5 -2 13.5l103 78 -110 190 -118 -50q-11 8 -23 15t-24 12L590 -80H370Zm70 -80h79l14 -106q31 -8 57.5 -23.5T639 -327l99 41 39 -68 -86 -65q5 -14 7 -29.5t2 -31.5q0 -16 -2 -31.5t-7 -29.5l86 -65 -39 -68 -99 42q-22 -23 -48.5 -38.5T533 -694l-13 -106h-79l-14 106q-31 8 -57.5 23.5T321 -633l-99 -41 -39 68 86 64q-5 15 -7 30t-2 32q0 16 2 31t7 30l-86 65 39 68 99 -42q22 23 48.5 38.5T427 -266l13 106Zm42 -180q58 0 99 -41t41 -99q0 -58 -41 -99t-99 -41q-59 0 -99.5 41T342 -480q0 58 40.5 99t99.5 41Zm-2 -140Z"
android:fillColor="#E3E3E3" />
</group>
</vector>

View File

@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/launcher_icon_background"/>
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/launcher_icon_foreground"/>
<monochrome android:drawable="@drawable/launcher_icon_monochrome"/>
</adaptive-icon>

View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/launcher_icon_background"/>
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/launcher_icon_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Softer background for normal dark -->
<color name="primaryText">#DEDEDE</color>
<color name="primaryBackground">#23272E</color>
<color name="dialogBackground">#31343A</color>
<color name="borderColor">#424242</color>
<!-- AMOLED (optionally override in new values-amoled/colors.xml) -->
</resources>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<resources>
<style name="AppTheme" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="android:fontFamily">@font/avenir_next_medium</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowContentOverlay">@null</item>
<!-- Use a softer dark for 'normal' dark mode, true AMOLED in amoled mode only (see below) -->
<item name="android:navigationBarColor">@color/primaryBackground</item>
<item name="android:textColor">@color/primaryText</item>
<item name="android:editTextColor">@color/primaryText</item>
<item name="android:alertDialogTheme">@android:style/Theme.DeviceDefault.Dialog.Alert</item>
</style>
</resources>

View File

@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="primaryText">#DEDEDE</color>
<color name="primaryBackground">#121212</color>
<color name="borderColor">#424242</color>
</resources>
<color name="primaryText">#000000</color>
<color name="primaryBackground">#FFFFFF</color>
<color name="dialogBackground">#FAFAFA</color>
<color name="borderColor">#DDDDDD</color>
</resources>

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="launcher_icon_background">#FDFA00</color>
<color name="ic_launcher_background">#edc23e</color>
</resources>

View File

@@ -1,3 +1,3 @@
<resources>
<string name="app_name" translatable="false">SnapEnhance</string>
<string name="app_name" translatable="false">PurrfectSnap</string>
</resources>

View File

@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8" ?>
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme">
<!-- Base app theme -->
<style name="AppTheme" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="android:fontFamily">@font/avenir_next_medium</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowContentOverlay">@null</item>
@@ -9,6 +10,8 @@
<item name="android:editTextColor">@color/primaryText</item>
<item name="android:alertDialogTheme">@android:style/Theme.DeviceDefault.Dialog.Alert</item>
</style>
<!-- Biometric prompt dialog theme (unchanged, but modernized for clarity) -->
<style name="BiometricPromptTheme" parent="AppTheme">
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowIsTranslucent">true</item>
@@ -16,4 +19,4 @@
<item name="android:windowContentOverlay">@null</item>
<item name="android:backgroundDimEnabled">false</item>
</style>
</resources>
</resources>

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-path name="external_files" path="."/>
<external-cache-path name="external_cache" path="." />
</paths>