feat: Initial commit!
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
6
app/proguard-rules.pro
vendored
@@ -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 { *; }
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, { }, { })
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +144,7 @@ class ManageScope: Routes.Route() {
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(routes.bottomPadding))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
13
app/src/main/res/drawable/ic_manage.xml
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 8.4 KiB |
BIN
app/src/main/res/mipmap-mdpi/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 4.5 KiB |
BIN
app/src/main/res/mipmap-xhdpi/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 12 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 23 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 34 KiB |
9
app/src/main/res/values-night/colors.xml
Normal 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>
|
||||
13
app/src/main/res/values-night/themes.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -1,3 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name" translatable="false">SnapEnhance</string>
|
||||
<string name="app_name" translatable="false">PurrfectSnap</string>
|
||||
</resources>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||