Merge branch 'RSR' of https://github.com/RSR1337/PurrfectSnap into RSR
This commit is contained in:
@@ -151,7 +151,7 @@ android {
|
||||
applicationId = rootProject.ext["applicationId"].toString()
|
||||
versionCode = rootProject.ext["appVersionCode"].toString().toInt()
|
||||
versionName = rootProject.ext["appVersionName"].toString()
|
||||
minSdk = 28
|
||||
minSdk = 30
|
||||
targetSdk = 36
|
||||
multiDexEnabled = true
|
||||
buildConfigField("String", "EXPECTED_CERT_SHA256", "\"${expectedCertSha256.get()}\"")
|
||||
@@ -267,6 +267,7 @@ dependencies {
|
||||
}
|
||||
|
||||
implementation(project(":core"))
|
||||
compileOnly(files("../core/libs/LSPosed-api-1.0-SNAPSHOT.jar"))
|
||||
implementation(project(":common"))
|
||||
implementation(project(":native"))
|
||||
implementation(libs.androidx.documentfile)
|
||||
|
||||
4
app/proguard-rules.pro
vendored
4
app/proguard-rules.pro
vendored
@@ -1,6 +1,6 @@
|
||||
|
||||
-dontwarn de.robv.android.xposed.**
|
||||
-dontwarn org.mozilla.javascript.**
|
||||
-dontwarn android.app.AndroidAppHelper
|
||||
-dontwarn java.lang.reflect.AnnotatedType
|
||||
|
||||
-keep class com.tonyodev.fetch2.** { *; }
|
||||
-keep class com.tonyodev.fetch2core.** { *; }
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1 +1 @@
|
||||
0.7
|
||||
0.8
|
||||
@@ -4,6 +4,7 @@ import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.os.RemoteException
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.SharedContextHolder
|
||||
@@ -27,10 +28,26 @@ import kotlin.system.measureTimeMillis
|
||||
|
||||
class BridgeService : Service() {
|
||||
private lateinit var remoteSideContext: RemoteSideContext
|
||||
lateinit var syncCallback: SyncCallback
|
||||
private var syncCallback: SyncCallback? = null
|
||||
private var syncCallbackBinder: IBinder? = null
|
||||
private val syncCallbackDeathRecipient = IBinder.DeathRecipient {
|
||||
remoteSideContext.takeIf { ::remoteSideContext.isInitialized }?.log?.warn("Sync callback binder died")
|
||||
clearSyncCallback()
|
||||
}
|
||||
var messagingBridge: MessagingBridge? = null
|
||||
|
||||
private fun clearSyncCallback() {
|
||||
syncCallbackBinder?.let { binder ->
|
||||
runCatching {
|
||||
binder.unlinkToDeath(syncCallbackDeathRecipient, 0)
|
||||
}
|
||||
}
|
||||
syncCallbackBinder = null
|
||||
syncCallback = null
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
clearSyncCallback()
|
||||
if (::remoteSideContext.isInitialized) {
|
||||
remoteSideContext.bridgeService = null
|
||||
}
|
||||
@@ -47,8 +64,10 @@ class BridgeService : Service() {
|
||||
}
|
||||
|
||||
fun triggerScopeSync(scope: SocialScope, id: String, updateOnly: Boolean = false) {
|
||||
val callback = syncCallback ?: return
|
||||
runCatching {
|
||||
if (!syncCallback.asBinder().pingBinder()) {
|
||||
if (!callback.asBinder().pingBinder()) {
|
||||
clearSyncCallback()
|
||||
remoteSideContext.log.warn("Failed to sync $scope $id: Callback is dead")
|
||||
return
|
||||
}
|
||||
@@ -57,26 +76,52 @@ class BridgeService : Service() {
|
||||
val syncedObject = when (scope) {
|
||||
SocialScope.FRIEND -> {
|
||||
if (updateOnly && database.getFriendInfo(id) == null) return
|
||||
syncCallback.syncFriend(id)
|
||||
callback.syncFriend(id)
|
||||
}
|
||||
SocialScope.GROUP -> {
|
||||
if (updateOnly && database.getGroupInfo(id) == null) return
|
||||
syncCallback.syncGroup(id)
|
||||
callback.syncGroup(id)
|
||||
}
|
||||
} ?: run {
|
||||
if (updateOnly) {
|
||||
when (scope) {
|
||||
SocialScope.FRIEND -> database.deleteFriend(id)
|
||||
SocialScope.GROUP -> database.deleteGroup(id)
|
||||
}
|
||||
return
|
||||
}
|
||||
remoteSideContext.log.warn("Failed to sync $scope $id")
|
||||
return
|
||||
}
|
||||
|
||||
when (scope) {
|
||||
SocialScope.FRIEND -> {
|
||||
toParcelable<MessagingFriendInfo>(syncedObject)?.let { database.syncFriend(it) }
|
||||
toParcelable<MessagingFriendInfo>(syncedObject)?.let { database.syncFriend(it) } ?: run {
|
||||
if (updateOnly) {
|
||||
database.deleteFriend(id)
|
||||
return
|
||||
}
|
||||
remoteSideContext.log.warn("Failed to sync $scope $id")
|
||||
return
|
||||
}
|
||||
}
|
||||
SocialScope.GROUP -> {
|
||||
toParcelable<MessagingGroupInfo>(syncedObject)?.let { database.syncGroupInfo(it) }
|
||||
toParcelable<MessagingGroupInfo>(syncedObject)?.let { database.syncGroupInfo(it) } ?: run {
|
||||
if (updateOnly) {
|
||||
database.deleteGroup(id)
|
||||
return
|
||||
}
|
||||
remoteSideContext.log.warn("Failed to sync $scope $id")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
if (it is RemoteException) {
|
||||
clearSyncCallback()
|
||||
remoteSideContext.log.warn("Failed to sync $scope $id: Callback is dead")
|
||||
return@onFailure
|
||||
}
|
||||
remoteSideContext.log.error("Failed to sync $scope $id", it)
|
||||
}
|
||||
}
|
||||
@@ -121,7 +166,7 @@ class BridgeService : Service() {
|
||||
val pendingTask = remoteSideContext.taskManager.createPendingTask(
|
||||
Task(
|
||||
type = TaskType.DOWNLOAD,
|
||||
title = "Media conversion",
|
||||
title = remoteSideContext.translation["task_media_conversion_title"],
|
||||
author = null,
|
||||
hash = taskId
|
||||
)
|
||||
@@ -162,7 +207,16 @@ class BridgeService : Service() {
|
||||
}
|
||||
|
||||
override fun sync(callback: SyncCallback) {
|
||||
clearSyncCallback()
|
||||
syncCallback = callback
|
||||
syncCallbackBinder = callback.asBinder().also { binder ->
|
||||
runCatching {
|
||||
binder.linkToDeath(syncCallbackDeathRecipient, 0)
|
||||
}.onFailure {
|
||||
clearSyncCallback()
|
||||
throw it
|
||||
}
|
||||
}
|
||||
measureTimeMillis {
|
||||
remoteSideContext.database.getFriends().map { it.userId } .forEach { friendId ->
|
||||
triggerScopeSync(SocialScope.FRIEND, friendId, true)
|
||||
@@ -185,10 +239,10 @@ class BridgeService : Service() {
|
||||
friends: List<String>
|
||||
) {
|
||||
remoteSideContext.log.verbose("Received ${groups.size} groups and ${friends.size} friends")
|
||||
remoteSideContext.database.receiveMessagingDataCallback(
|
||||
friends.mapNotNull { toParcelable<MessagingFriendInfo>(it) },
|
||||
groups.mapNotNull { toParcelable<MessagingGroupInfo>(it) }
|
||||
)
|
||||
val parsedFriends = friends.mapNotNull { toParcelable<MessagingFriendInfo>(it) }
|
||||
val parsedGroups = groups.mapNotNull { toParcelable<MessagingGroupInfo>(it) }
|
||||
remoteSideContext.database.replaceMessagingData(parsedFriends, parsedGroups)
|
||||
remoteSideContext.database.receiveMessagingDataCallback(parsedFriends, parsedGroups)
|
||||
}
|
||||
|
||||
override fun getScopeNotes(id: String): String? {
|
||||
|
||||
@@ -61,6 +61,10 @@ class DownloadProcessor (
|
||||
private val remoteSideContext: RemoteSideContext,
|
||||
private val callback: DownloadCallback
|
||||
) {
|
||||
private data class GallerySaveResult(
|
||||
val uri: Uri,
|
||||
val alreadyDownloaded: Boolean = false
|
||||
)
|
||||
|
||||
private val translation by lazy {
|
||||
remoteSideContext.translation.getCategory("download_processor")
|
||||
@@ -120,76 +124,36 @@ class DownloadProcessor (
|
||||
|
||||
val fileName = metadata.outputPath.substringAfterLast("/") + "." + fileType.fileExtension
|
||||
val configuredFolder = remoteSideContext.config.root.downloader.saveFolder.get().orEmpty().trim()
|
||||
if (configuredFolder.isBlank()) {
|
||||
val outputUri = saveToSystemDefault(fileName, fileType, inputFile, metadata)
|
||||
?: throw Exception("Failed to save media (no output uri)")
|
||||
pendingTask.task.extra = outputUri.toString()
|
||||
pendingTask.success()
|
||||
callbackOnSuccess(fileName)
|
||||
return
|
||||
}
|
||||
|
||||
val outputFolder = DocumentFile.fromTreeUri(remoteSideContext.androidContext, Uri.parse(configuredFolder))
|
||||
?: throw Exception("Failed to open output folder")
|
||||
|
||||
val outputFileFolder = metadata.outputPath.let {
|
||||
if (it.contains("/")) {
|
||||
it.substringBeforeLast("/").split("/").fold(outputFolder) { folder, name ->
|
||||
folder.findFile(name) ?: folder.createDirectory(name)!!
|
||||
}
|
||||
} else {
|
||||
outputFolder
|
||||
val saveResult = if (configuredFolder.isBlank()) {
|
||||
saveToSystemDefault(fileName, fileType, inputFile, metadata)?.let { GallerySaveResult(it) }
|
||||
} else {
|
||||
runCatching {
|
||||
saveToConfiguredFolder(
|
||||
configuredFolder = configuredFolder,
|
||||
fileName = fileName,
|
||||
fileType = fileType,
|
||||
inputFile = inputFile,
|
||||
metadata = metadata,
|
||||
pendingTask = pendingTask
|
||||
)
|
||||
}.onFailure {
|
||||
remoteSideContext.log.error("Failed to save to configured folder, falling back to system default", it)
|
||||
}.getOrNull() ?: saveToSystemDefault(fileName, fileType, inputFile, metadata)?.let {
|
||||
GallerySaveResult(it)
|
||||
}
|
||||
}
|
||||
} ?: throw Exception("Failed to save media (no output uri)")
|
||||
|
||||
// checks if the file already exists and if it does, compares its contents with the input file, if contents differ, deletes existing file.
|
||||
outputFileFolder.findFile(fileName)?.let { existingFile ->
|
||||
pendingTask.updateProgress("Comparing existing media")
|
||||
if (existingFile.length() != inputFile.length()) {
|
||||
existingFile.delete()
|
||||
return@let
|
||||
}
|
||||
pendingTask.task.extra = saveResult.uri.toString()
|
||||
pendingTask.success()
|
||||
|
||||
remoteSideContext.androidContext.contentResolver.openInputStream(existingFile.uri)?.use { existingInputStream ->
|
||||
val buffer1 = ByteArray(1024 * 1024)
|
||||
val buffer2 = ByteArray(1024 * 1024)
|
||||
var read1: Int
|
||||
var read2: Int
|
||||
|
||||
inputFile.inputStream().use { inputStream ->
|
||||
while (true) {
|
||||
read1 = inputStream.read(buffer1)
|
||||
read2 = existingInputStream.read(buffer2)
|
||||
if (read1 != read2 || !buffer1.contentEquals(buffer2)) {
|
||||
existingFile.delete()
|
||||
return@let
|
||||
}
|
||||
if (read1 == -1) break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pendingTask.task.extra = existingFile.uri.toString()
|
||||
pendingTask.success()
|
||||
if (saveResult.alreadyDownloaded) {
|
||||
callbackOnFailure(translation["already_downloaded_toast"])
|
||||
return
|
||||
}
|
||||
|
||||
val outputFile = outputFileFolder.createFile(fileType.mimeType, fileName)!!
|
||||
|
||||
pendingTask.updateProgress("Saving media to gallery")
|
||||
remoteSideContext.androidContext.contentResolver.openOutputStream(outputFile.uri)!!.use { outputStream ->
|
||||
inputFile.inputStream().use { inputStream ->
|
||||
inputStream.copyTo(outputStream)
|
||||
}
|
||||
}
|
||||
|
||||
pendingTask.task.extra = outputFile.uri.toString()
|
||||
pendingTask.success()
|
||||
|
||||
runCatching {
|
||||
remoteSideContext.androidContext.sendBroadcast(Intent("android.intent.action.MEDIA_SCANNER_SCAN_FILE").apply {
|
||||
data = outputFile.uri
|
||||
data = saveResult.uri
|
||||
})
|
||||
}.onFailure {
|
||||
remoteSideContext.log.error("Failed to scan media file", it)
|
||||
@@ -205,6 +169,83 @@ class DownloadProcessor (
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveToConfiguredFolder(
|
||||
configuredFolder: String,
|
||||
fileName: String,
|
||||
fileType: FileType,
|
||||
inputFile: File,
|
||||
metadata: DownloadMetadata,
|
||||
pendingTask: PendingTask,
|
||||
): GallerySaveResult {
|
||||
val outputFolder = DocumentFile.fromTreeUri(remoteSideContext.androidContext, Uri.parse(configuredFolder))
|
||||
?: throw Exception("Failed to open output folder")
|
||||
|
||||
val outputFileFolder = metadata.outputPath.let {
|
||||
if (it.contains("/")) {
|
||||
it.substringBeforeLast("/").split("/").fold(outputFolder) { folder, name ->
|
||||
folder.findFile(name)
|
||||
?: folder.createDirectory(name)
|
||||
?: throw Exception("Failed to create output directory $name")
|
||||
}
|
||||
} else {
|
||||
outputFolder
|
||||
}
|
||||
}
|
||||
|
||||
outputFileFolder.findFile(fileName)?.let { existingFile ->
|
||||
pendingTask.updateProgress("Comparing existing media")
|
||||
if (existingFile.length() != inputFile.length()) {
|
||||
existingFile.delete()
|
||||
} else {
|
||||
val existingInputStream = remoteSideContext.androidContext.contentResolver.openInputStream(existingFile.uri)
|
||||
?: throw Exception("Failed to open existing media for comparison")
|
||||
|
||||
existingInputStream.use { currentExistingInputStream ->
|
||||
val buffer1 = ByteArray(1024 * 1024)
|
||||
val buffer2 = ByteArray(1024 * 1024)
|
||||
var read1: Int
|
||||
var read2: Int
|
||||
|
||||
inputFile.inputStream().use { inputStream ->
|
||||
while (true) {
|
||||
read1 = inputStream.read(buffer1)
|
||||
read2 = currentExistingInputStream.read(buffer2)
|
||||
if (read1 != read2 || (read1 > 0 && !buffersMatch(buffer1, buffer2, read1))) {
|
||||
existingFile.delete()
|
||||
return@let
|
||||
}
|
||||
if (read1 == -1) break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return GallerySaveResult(existingFile.uri, alreadyDownloaded = true)
|
||||
}
|
||||
}
|
||||
|
||||
val outputFile = outputFileFolder.createFile(fileType.mimeType, fileName)
|
||||
?: throw Exception("Failed to create output file $fileName")
|
||||
|
||||
pendingTask.updateProgress("Saving media to gallery")
|
||||
val outputStream = remoteSideContext.androidContext.contentResolver.openOutputStream(outputFile.uri)
|
||||
?: throw Exception("Failed to open output stream for $fileName")
|
||||
|
||||
outputStream.use { currentOutputStream ->
|
||||
inputFile.inputStream().use { inputStream ->
|
||||
inputStream.copyTo(currentOutputStream)
|
||||
}
|
||||
}
|
||||
|
||||
return GallerySaveResult(outputFile.uri)
|
||||
}
|
||||
|
||||
private fun buffersMatch(left: ByteArray, right: ByteArray, length: Int): Boolean {
|
||||
for (index in 0 until length) {
|
||||
if (left[index] != right[index]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun saveToSystemDefault(
|
||||
fileName: String,
|
||||
fileType: FileType,
|
||||
|
||||
@@ -232,6 +232,7 @@ class FFMpegProcessor(
|
||||
}
|
||||
globalArguments += "-ar" to args.audioStreamFormat.sampleRate.toString()
|
||||
globalArguments += "-ac" to args.audioStreamFormat.channels.toString()
|
||||
outputArguments += "-c:a" to "pcm_s16le"
|
||||
}
|
||||
Action.MERGE_AUDIO_STREAMS -> {
|
||||
inputArguments.clear()
|
||||
@@ -240,18 +241,23 @@ class FFMpegProcessor(
|
||||
args.inputs.forEachIndexed { index, input ->
|
||||
inputArguments += "-i" to input
|
||||
val offset = args.inputDelayOffsets?.get(input) ?: 0L
|
||||
filterParts.append("[$index:a]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo")
|
||||
if (offset > 0) {
|
||||
filterParts.append("[$index:a]adelay=$offset|$offset[a$index];")
|
||||
filterParts.append(",adelay=$offset|$offset[a$index];")
|
||||
} else {
|
||||
filterParts.append("[$index:a]acopy[a$index];")
|
||||
filterParts.append(",acopy[a$index];")
|
||||
}
|
||||
}
|
||||
args.inputs.indices.forEach { index ->
|
||||
filterParts.append("[a$index]")
|
||||
}
|
||||
filterParts.append("amix=inputs=${args.inputs.size}:duration=longest:normalize=0[aout]")
|
||||
filterParts.append("amix=inputs=${args.inputs.size}:duration=longest:dropout_transition=0:normalize=1,alimiter=limit=0.95[aout]")
|
||||
outputArguments += "-filter_complex" to "\"$filterParts\""
|
||||
outputArguments += "-map" to "\"[aout]\""
|
||||
outputArguments += "-c:a" to "libmp3lame"
|
||||
outputArguments += "-b:a" to "192k"
|
||||
outputArguments += "-ar" to "48000"
|
||||
outputArguments += "-ac" to "2"
|
||||
}
|
||||
}
|
||||
outputArguments += args.output.absolutePath
|
||||
|
||||
@@ -46,7 +46,7 @@ class CallDownloadSessionImpl(
|
||||
val job: Job
|
||||
val writePfd: ParcelFileDescriptor
|
||||
|
||||
val outputFile = context.androidContext.cacheDir.resolve("call_${UUID.randomUUID()}.mp3").apply {
|
||||
val outputFile = context.androidContext.cacheDir.resolve("call_${UUID.randomUUID()}.wav").apply {
|
||||
if (exists()) delete()
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ class CallDownloadSessionImpl(
|
||||
val pendingTask = context.taskManager.createPendingTask(
|
||||
Task(
|
||||
type = TaskType.DOWNLOAD,
|
||||
title = "Call Recording $author",
|
||||
title = context.translation.format("task_call_recording_title", "author" to author),
|
||||
author = author,
|
||||
hash = UUID.randomUUID().toString()
|
||||
)
|
||||
@@ -188,4 +188,4 @@ class CallDownloadSessionImpl(
|
||||
context.log.verbose("ending call")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,76 @@ fun AppDatabase.syncFriend(friend: MessagingFriendInfo) {
|
||||
}
|
||||
}
|
||||
|
||||
fun AppDatabase.replaceMessagingData(
|
||||
friends: List<MessagingFriendInfo>,
|
||||
groups: List<MessagingGroupInfo>
|
||||
) {
|
||||
executeAsync {
|
||||
database.beginTransaction()
|
||||
try {
|
||||
val friendIds = friends.map { it.userId }.toSet()
|
||||
val groupIds = groups.map { it.conversationId }.toSet()
|
||||
|
||||
getFriends().forEach { friend ->
|
||||
if (friend.userId !in friendIds) {
|
||||
database.execSQL("DELETE FROM friends WHERE userId = ?", arrayOf(friend.userId))
|
||||
database.execSQL("DELETE FROM streaks WHERE id = ?", arrayOf(friend.userId))
|
||||
database.execSQL("DELETE FROM rules WHERE targetUuid = ?", arrayOf(friend.userId))
|
||||
}
|
||||
}
|
||||
|
||||
getGroups().forEach { group ->
|
||||
if (group.conversationId !in groupIds) {
|
||||
database.execSQL("DELETE FROM groups WHERE conversationId = ?", arrayOf(group.conversationId))
|
||||
database.execSQL("DELETE FROM rules WHERE targetUuid = ?", arrayOf(group.conversationId))
|
||||
}
|
||||
}
|
||||
|
||||
friends.forEach { friend ->
|
||||
database.execSQL(
|
||||
"INSERT OR REPLACE INTO friends (userId, dmConversationId, displayName, mutableUsername, bitmojiId, selfieId) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
arrayOf<Any?>(
|
||||
friend.userId,
|
||||
friend.dmConversationId,
|
||||
friend.displayName,
|
||||
friend.mutableUsername,
|
||||
friend.bitmojiId,
|
||||
friend.selfieId
|
||||
)
|
||||
)
|
||||
|
||||
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<Any?>(
|
||||
friend.userId,
|
||||
streaks?.notify != false,
|
||||
it.expirationTimestamp,
|
||||
it.length
|
||||
)
|
||||
)
|
||||
} ?: database.execSQL("DELETE FROM streaks WHERE id = ?", arrayOf(friend.userId))
|
||||
}
|
||||
|
||||
groups.forEach { group ->
|
||||
database.execSQL(
|
||||
"INSERT OR REPLACE INTO groups (conversationId, name, participantsCount) VALUES (?, ?, ?)",
|
||||
arrayOf<Any?>(
|
||||
group.conversationId,
|
||||
group.name,
|
||||
group.participantsCount
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
database.setTransactionSuccessful()
|
||||
} finally {
|
||||
database.endTransaction()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun AppDatabase.getRules(targetUuid: String): List<MessagingRuleType> {
|
||||
return database.rawQuery(
|
||||
"SELECT type FROM rules WHERE targetUuid = ?", arrayOf(targetUuid)
|
||||
|
||||
@@ -132,16 +132,16 @@ class MainActivity : ComponentActivity() {
|
||||
if (shouldShowAbiWarning) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = {},
|
||||
title = "Wrong APK installed",
|
||||
title = managerContext.translation["wrong_apk_title"],
|
||||
text = "",
|
||||
icon = Icons.Filled.Warning,
|
||||
confirmButtonText = "Close",
|
||||
confirmButtonText = managerContext.translation["common.close"],
|
||||
onConfirm = { (context as? Activity)?.finishAffinity() },
|
||||
showCloseButton = false,
|
||||
opaque = true,
|
||||
customContent = {
|
||||
Text(
|
||||
text = "Your device is armv8, please download the armv8 apk, not armv7.",
|
||||
text = managerContext.translation["wrong_apk_message"],
|
||||
color = PurrfectPalette.textSecondary,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
|
||||
@@ -794,7 +794,9 @@ class Navigation(
|
||||
} 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) } }
|
||||
children.forEach { child ->
|
||||
composable(child.routeInfo.id) { child.content.invoke(it) }
|
||||
}
|
||||
route.customComposables.invoke(this)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package me.eternal.purrfectsnap.ui.manager
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
|
||||
@Composable
|
||||
fun rememberRouteScrollState(key: String): ScrollState {
|
||||
val scrollState = remember(key) {
|
||||
ScrollState(RouteStateCache.scrollOffsets[key] ?: 0)
|
||||
}
|
||||
LaunchedEffect(key, scrollState.value) {
|
||||
RouteStateCache.scrollOffsets[key] = scrollState.value
|
||||
}
|
||||
DisposableEffect(key, scrollState) {
|
||||
onDispose {
|
||||
RouteStateCache.scrollOffsets[key] = scrollState.value
|
||||
}
|
||||
}
|
||||
return scrollState
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberRouteLazyListState(key: String): LazyListState {
|
||||
val savedState = RouteStateCache.lazyListOffsets[key]
|
||||
val listState = remember(key) {
|
||||
LazyListState(
|
||||
firstVisibleItemIndex = savedState?.first ?: 0,
|
||||
firstVisibleItemScrollOffset = savedState?.second ?: 0
|
||||
)
|
||||
}
|
||||
LaunchedEffect(key, listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) {
|
||||
RouteStateCache.lazyListOffsets[key] =
|
||||
listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset
|
||||
}
|
||||
DisposableEffect(key, listState) {
|
||||
onDispose {
|
||||
RouteStateCache.lazyListOffsets[key] =
|
||||
listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset
|
||||
}
|
||||
}
|
||||
return listState
|
||||
}
|
||||
|
||||
private object RouteStateCache {
|
||||
val scrollOffsets = mutableMapOf<String, Int>()
|
||||
val lazyListOffsets = mutableMapOf<String, Pair<Int, Int>>()
|
||||
}
|
||||
@@ -54,7 +54,7 @@ class Routes(
|
||||
) {
|
||||
companion object {
|
||||
const val CONFIG_IMPORT_CONFIRMATION_ROUTE = "config_import_confirmation"
|
||||
const val CONFIG_EXPORT_SUMMARY_ROUTE = "config_export_summary/?exportSensitiveData={exportSensitiveData}"
|
||||
const val CONFIG_EXPORT_SUMMARY_ROUTE = "config_export_summary/?exportSensitiveData={exportSensitiveData}&includeSavedLocations={includeSavedLocations}"
|
||||
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"
|
||||
const val VIEW_LOGGER_HISTORY_ROUTE = "view_logger_history/{uri}"
|
||||
|
||||
@@ -13,11 +13,14 @@ import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.zip.ZipInputStream
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
|
||||
object UpdateDownloader {
|
||||
private const val TAG = "UpdateDownloader"
|
||||
private var fetch: Fetch? = null
|
||||
private var listener: FetchListener? = null
|
||||
private val fallbackHttpClient by lazy { OkHttpClient() }
|
||||
|
||||
private fun getInstance(context: RemoteSideContext): Fetch {
|
||||
fetch?.let { return it }
|
||||
@@ -84,6 +87,132 @@ object UpdateDownloader {
|
||||
return downloadedFile
|
||||
}
|
||||
|
||||
private fun scheduleReset(scope: CoroutineScope) {
|
||||
scope.launch {
|
||||
delay(2000)
|
||||
downloadState.value = DownloadState.IDLE
|
||||
downloadProgress.value = 0f
|
||||
}
|
||||
}
|
||||
|
||||
private fun installDownloadedFile(
|
||||
remoteContext: RemoteSideContext,
|
||||
downloadedFile: File,
|
||||
scope: CoroutineScope
|
||||
) {
|
||||
val context = remoteContext.androidContext
|
||||
val translation = remoteContext.translation.getCategory("manager.sections.home")
|
||||
downloadState.value = DownloadState.COMPLETED
|
||||
runCatching {
|
||||
remoteContext.log.info(
|
||||
"Download completed -> ${downloadedFile.absolutePath} (${downloadedFile.length()} bytes)",
|
||||
TAG
|
||||
)
|
||||
Toast.makeText(context, translation["update_download_completed_toast"], Toast.LENGTH_SHORT).show()
|
||||
val apkFile = resolveDownloadedApk(remoteContext, downloadedFile)
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.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)
|
||||
}
|
||||
remoteContext.log.info("Launching installer for ${apkFile.absolutePath}", TAG)
|
||||
context.startActivity(installIntent)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
delay(30_000)
|
||||
runCatching { downloadedFile.delete() }
|
||||
apkFile.parentFile
|
||||
?.takeIf { it.name == "update" }
|
||||
?.let { dir -> runCatching { dir.deleteRecursively() } }
|
||||
remoteContext.log.info("Cleaned downloaded update files", TAG)
|
||||
}
|
||||
}.onFailure {
|
||||
Toast.makeText(context, translation["update_install_failed_toast"], Toast.LENGTH_SHORT).show()
|
||||
remoteContext.log.error("Failed to install downloaded update", it, TAG)
|
||||
downloadState.value = DownloadState.FAILED
|
||||
}
|
||||
scheduleReset(scope)
|
||||
}
|
||||
|
||||
private fun failDownload(
|
||||
remoteContext: RemoteSideContext,
|
||||
scope: CoroutineScope,
|
||||
errorMessage: String,
|
||||
throwable: Throwable? = null
|
||||
) {
|
||||
val context = remoteContext.androidContext
|
||||
val translation = remoteContext.translation.getCategory("manager.sections.home")
|
||||
downloadState.value = DownloadState.FAILED
|
||||
Toast.makeText(
|
||||
context,
|
||||
translation.format("update_download_failed_toast", "error" to errorMessage),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
throwable?.let { remoteContext.log.error("Update download failed: $errorMessage", it, TAG) }
|
||||
?: remoteContext.log.error("Update download failed: $errorMessage", TAG)
|
||||
scheduleReset(scope)
|
||||
}
|
||||
|
||||
private fun startHttpFallbackDownload(
|
||||
remoteContext: RemoteSideContext,
|
||||
downloadUrl: String,
|
||||
filePath: String,
|
||||
scope: CoroutineScope
|
||||
) {
|
||||
val partialFile = File("$filePath.part")
|
||||
val outputFile = File(filePath)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
remoteContext.log.warn("Fetch download failed, retrying update download via OkHttp fallback", TAG)
|
||||
partialFile.parentFile?.mkdirs()
|
||||
if (partialFile.exists()) partialFile.delete()
|
||||
if (outputFile.exists()) outputFile.delete()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(downloadUrl)
|
||||
.header("User-Agent", "PurrfectSnap-Updater")
|
||||
.build()
|
||||
|
||||
fallbackHttpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw IllegalStateException("HTTP_${response.code}")
|
||||
}
|
||||
|
||||
val body = response.body ?: throw IllegalStateException("EMPTY_RESPONSE_BODY")
|
||||
val contentLength = body.contentLength()
|
||||
var downloadedBytes = 0L
|
||||
|
||||
body.byteStream().use { input ->
|
||||
partialFile.outputStream().use { output ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
output.write(buffer, 0, read)
|
||||
downloadedBytes += read
|
||||
if (contentLength > 0) {
|
||||
downloadProgress.value = downloadedBytes.toFloat() / contentLength.toFloat()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!partialFile.renameTo(outputFile)) {
|
||||
partialFile.copyTo(outputFile, overwrite = true)
|
||||
partialFile.delete()
|
||||
}
|
||||
installDownloadedFile(remoteContext, outputFile, scope)
|
||||
}.onFailure {
|
||||
runCatching { partialFile.delete() }
|
||||
failDownload(remoteContext, scope, "FALLBACK_${it.message ?: "UNKNOWN"}", it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadAndInstall(
|
||||
remoteContext: RemoteSideContext,
|
||||
downloadUrl: String,
|
||||
@@ -100,6 +229,7 @@ object UpdateDownloader {
|
||||
networkType = NetworkType.ALL
|
||||
}
|
||||
listener?.let { fetch.removeListener(it) }
|
||||
var fallbackAttempted = false
|
||||
listener = object : AbstractFetchListener() {
|
||||
override fun onAdded(download: Download) {
|
||||
downloadState.value = DownloadState.DOWNLOADING
|
||||
@@ -116,60 +246,21 @@ object UpdateDownloader {
|
||||
}
|
||||
|
||||
override fun onCompleted(download: Download) {
|
||||
downloadState.value = DownloadState.COMPLETED
|
||||
runCatching {
|
||||
val downloadedFile = File(download.file)
|
||||
remoteContext.log.info(
|
||||
"Download completed -> ${downloadedFile.absolutePath} (${downloadedFile.length()} bytes)",
|
||||
TAG
|
||||
)
|
||||
Toast.makeText(context, translation["update_download_completed_toast"], Toast.LENGTH_SHORT).show()
|
||||
val apkFile = resolveDownloadedApk(remoteContext, downloadedFile)
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.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)
|
||||
}
|
||||
remoteContext.log.info("Launching installer for ${apkFile.absolutePath}", TAG)
|
||||
context.startActivity(installIntent)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
delay(30_000)
|
||||
runCatching { downloadedFile.delete() }
|
||||
apkFile.parentFile
|
||||
?.takeIf { it.name == "update" }
|
||||
?.let { dir -> runCatching { dir.deleteRecursively() } }
|
||||
remoteContext.log.info("Cleaned downloaded update files", TAG)
|
||||
}
|
||||
}.onFailure {
|
||||
Toast.makeText(context, translation["update_install_failed_toast"], Toast.LENGTH_SHORT).show()
|
||||
remoteContext.log.error("Failed to install downloaded update", it, TAG)
|
||||
downloadState.value = DownloadState.FAILED
|
||||
}
|
||||
installDownloadedFile(remoteContext, File(download.file), scope)
|
||||
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,
|
||||
translation.format("update_download_failed_toast", "error" to error.toString()),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
throwable?.let { remoteContext.log.error("Update download failed: $error", it, TAG) }
|
||||
?: remoteContext.log.error("Update download failed: $error", TAG)
|
||||
fetch.removeListener(this)
|
||||
scope.launch {
|
||||
delay(2000)
|
||||
downloadState.value = DownloadState.IDLE
|
||||
if (!fallbackAttempted && error == Error.REQUEST_NOT_SUCCESSFUL) {
|
||||
fallbackAttempted = true
|
||||
downloadState.value = DownloadState.DOWNLOADING
|
||||
downloadProgress.value = 0f
|
||||
remoteContext.log.warn("Fetch returned REQUEST_NOT_SUCCESSFUL, starting fallback downloader", TAG)
|
||||
startHttpFallbackDownload(remoteContext, downloadUrl, filePath, scope)
|
||||
return
|
||||
}
|
||||
failDownload(remoteContext, scope, error.toString(), throwable)
|
||||
}
|
||||
}
|
||||
fetch.addListener(listener!!)
|
||||
|
||||
@@ -143,7 +143,7 @@ class ManageReposSection: Routes.Route() {
|
||||
addRepo(url)
|
||||
}.onFailure {
|
||||
context.log.error("Failed to add repository", it)
|
||||
context.shortToast(translation.format("add_repo_failed", "message" to (it.message ?: "Unknown")))
|
||||
context.shortToast(translation.format("add_repo_failed", "message" to (it.message ?: context.translation["common.unknown"])))
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
|
||||
@@ -307,7 +307,7 @@ class TasksRootSection : Routes.Route() {
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
text = tasksTranslation.getOrNull("delete_files_option_hint") ?: "Also remove downloaded files from device",
|
||||
text = tasksTranslation.getOrNull("delete_files_option_hint") ?: "Permanently remove the original files from storage",
|
||||
color = PurrfectPalette.textSecondary,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
|
||||
@@ -52,6 +52,7 @@ import androidx.compose.ui.unit.sp
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.saveFile
|
||||
import me.eternal.purrfectsnap.storage.getLocationCoordinates
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
@@ -136,10 +137,14 @@ class ConfigExportSummaryScreen : Routes.Route() {
|
||||
|
||||
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
|
||||
val exportSensitiveData = it.arguments?.getString("exportSensitiveData")?.toBoolean() ?: false
|
||||
val exportLabel = context.translation["manager.sections.features.export_option"] ?: "Export"
|
||||
val includeSavedLocations = it.arguments?.getString("includeSavedLocations")?.toBoolean() ?: false
|
||||
val exportLabel = context.translation["manager.sections.features.export_option"]
|
||||
val parser = remember { ConfigParser() }
|
||||
val savedLocations = remember {
|
||||
if (includeSavedLocations) context.database.getLocationCoordinates() else null
|
||||
}
|
||||
val featuresByCategory = remember {
|
||||
parser.parse(context.config.exportToString(exportSensitiveData))
|
||||
parser.parse(context.config.exportToString(exportSensitiveData, includeSavedLocations, savedLocations))
|
||||
}
|
||||
val expandedState = remember { mutableStateMapOf<String, Boolean>() }
|
||||
|
||||
@@ -191,14 +196,14 @@ class ConfigExportSummaryScreen : Routes.Route() {
|
||||
tint = Color.White,
|
||||
modifier = Modifier.padding(end = 6.dp)
|
||||
)
|
||||
Text(context.translation["common.back"] ?: "Back")
|
||||
Text(context.translation["common.back"])
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.weight(1f),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Summary",
|
||||
text = translation["title"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp
|
||||
@@ -210,7 +215,7 @@ class ConfigExportSummaryScreen : Routes.Route() {
|
||||
runCatching {
|
||||
context.androidContext.contentResolver.openOutputStream(android.net.Uri.parse(uri))?.use {
|
||||
context.config.writeConfig()
|
||||
context.config.exportToString(exportSensitiveData).byteInputStream().copyTo(it)
|
||||
context.config.exportToString(exportSensitiveData, includeSavedLocations, savedLocations).byteInputStream().copyTo(it)
|
||||
context.shortToast(context.translation["manager.sections.features.config_export_success_toast"])
|
||||
}
|
||||
}.onFailure {
|
||||
|
||||
@@ -49,10 +49,14 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.bridge.location.LocationCoordinates
|
||||
import me.eternal.purrfectsnap.storage.addOrUpdateLocationCoordinate
|
||||
import me.eternal.purrfectsnap.storage.getLocationCoordinates
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import kotlin.math.abs
|
||||
|
||||
class ConfigImportConfirmationScreen : Routes.Route() {
|
||||
override val translation by lazy { context.translation.getCategory("manager.features.config_import") }
|
||||
@@ -65,6 +69,44 @@ class ConfigImportConfirmationScreen : Routes.Route() {
|
||||
val indentation: Int
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val COORDINATE_TOLERANCE = 0.0001 // ~11 meters tolerance for de-duplication
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports saved locations from JSON array into database with de-duplication.
|
||||
* Only adds locations that don't already exist (within coordinate tolerance).
|
||||
*/
|
||||
private fun importSavedLocations(locationsArray: com.google.gson.JsonArray) {
|
||||
val existingLocations = context.database.getLocationCoordinates()
|
||||
|
||||
for (i in 0 until locationsArray.size()) {
|
||||
val locationObj = locationsArray.get(i).asJsonObject
|
||||
val name = locationObj.get("name")?.asString ?: continue
|
||||
val latitude = locationObj.get("latitude")?.asDouble ?: continue
|
||||
val longitude = locationObj.get("longitude")?.asDouble ?: continue
|
||||
val radius = locationObj.get("radius")?.asDouble ?: 100.0
|
||||
|
||||
// Check for existing location with similar coordinates (de-duplication)
|
||||
val existingMatch = existingLocations.find { existing ->
|
||||
abs(existing.latitude - latitude) < COORDINATE_TOLERANCE &&
|
||||
abs(existing.longitude - longitude) < COORDINATE_TOLERANCE
|
||||
}
|
||||
|
||||
if (existingMatch == null) {
|
||||
// No duplicate found, add as new location
|
||||
val newLocation = LocationCoordinates().apply {
|
||||
this.name = name
|
||||
this.latitude = latitude
|
||||
this.longitude = longitude
|
||||
this.radius = radius
|
||||
}
|
||||
context.database.addOrUpdateLocationCoordinate(null, newLocation)
|
||||
}
|
||||
// If duplicate exists, skip (do not update or delete existing)
|
||||
}
|
||||
}
|
||||
|
||||
private inner class ConfigParser {
|
||||
fun parse(configJson: String): Map<String, List<ImportedFeature>> {
|
||||
val featureList = mutableListOf<ImportedFeature>()
|
||||
@@ -190,7 +232,7 @@ class ConfigImportConfirmationScreen : Routes.Route() {
|
||||
routes.configJsonForImport?.let { parser.parse(it) } ?: emptyMap()
|
||||
}
|
||||
val expandedState = remember { mutableStateMapOf<String, Boolean>() }
|
||||
val importLabel = translation["confirm_button"] ?: "Import"
|
||||
val importLabel = translation["confirm_button"]
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -239,14 +281,14 @@ class ConfigImportConfirmationScreen : Routes.Route() {
|
||||
tint = Color.White,
|
||||
modifier = Modifier.padding(end = 6.dp)
|
||||
)
|
||||
Text(context.translation["common.back"] ?: "Back")
|
||||
Text(context.translation["common.back"])
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.weight(1f),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Summary",
|
||||
text = translation["title"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp
|
||||
@@ -256,12 +298,17 @@ class ConfigImportConfirmationScreen : Routes.Route() {
|
||||
onClick = {
|
||||
routes.configJsonForImport?.let { json ->
|
||||
runCatching {
|
||||
context.config.loadFromString(json)
|
||||
val savedLocationsJson = context.config.loadFromString(json)
|
||||
|
||||
// Import saved locations if present in the JSON
|
||||
savedLocationsJson?.let { locationsArray ->
|
||||
importSavedLocations(locationsArray)
|
||||
}
|
||||
}.onFailure { err ->
|
||||
context.longToast(
|
||||
context.translation.format(
|
||||
"config_import_failure_toast",
|
||||
"error" to (err.message ?: "Unknown error")
|
||||
"error" to (err.message ?: context.translation["common.unknown_error"])
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ import com.google.gson.reflect.TypeToken
|
||||
import me.eternal.purrfectsnap.common.ui.TopBarActionButton
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.rememberRouteLazyListState
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.*
|
||||
import org.json.JSONArray
|
||||
@@ -112,7 +113,7 @@ class FeaturesRootSection : Routes.Route() {
|
||||
} ?: routeInfo.translatedKey?.value
|
||||
}
|
||||
SEARCH_FEATURE_ROUTE -> {
|
||||
translation["search_button"] ?: "Search"
|
||||
translation["search_button"]
|
||||
}
|
||||
else -> {
|
||||
routeInfo.translatedKey?.value
|
||||
@@ -134,7 +135,10 @@ class FeaturesRootSection : Routes.Route() {
|
||||
val containers = mutableMapOf<String, PropertyPair<*>>()
|
||||
fun queryContainerRecursive(container: ConfigContainer) {
|
||||
container.properties.forEach {
|
||||
if (it.key.dataType.type == DataProcessors.Type.CONTAINER) {
|
||||
if (
|
||||
it.key.dataType.type == DataProcessors.Type.CONTAINER &&
|
||||
!it.key.params.flags.contains(ConfigFlag.HIDDEN)
|
||||
) {
|
||||
containers[it.key.name] = PropertyPair(it.key, it.value)
|
||||
queryContainerRecursive(it.value.get() as ConfigContainer)
|
||||
}
|
||||
@@ -155,10 +159,15 @@ class FeaturesRootSection : Routes.Route() {
|
||||
properties
|
||||
}
|
||||
|
||||
private fun isSearchVisibleProperty(propertyKey: PropertyKey<*>): Boolean {
|
||||
return !propertyKey.params.flags.contains(ConfigFlag.HIDDEN)
|
||||
}
|
||||
|
||||
private data class SearchEntry(val keyword: String, val tokens: List<String>)
|
||||
|
||||
private fun buildSearchEntries(): List<SearchEntry> {
|
||||
return allProperties.keys.mapNotNull { key ->
|
||||
if (!isSearchVisibleProperty(key)) return@mapNotNull null
|
||||
val name = context.translation[key.propertyName()]
|
||||
val description = context.translation[key.propertyDescription()]
|
||||
val tokens = listOfNotNull(name, description, key.name).map { it.trim() }.filter { it.isNotEmpty() }
|
||||
@@ -246,7 +255,10 @@ class FeaturesRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
||||
Container(context.config.root)
|
||||
Container(
|
||||
configContainer = context.config.root,
|
||||
stateKey = "${routeInfo.id}:root"
|
||||
)
|
||||
}
|
||||
|
||||
override val customComposables: NavGraphBuilder.() -> Unit = {
|
||||
@@ -269,6 +281,7 @@ class FeaturesRootSection : Routes.Route() {
|
||||
val containerSubtitle = translation[it.key.propertyDescription()]
|
||||
Container(
|
||||
configContainer = it.value.get() as ConfigContainer,
|
||||
stateKey = "${routeInfo.id}:container:$containerName",
|
||||
sectionTitle = containerTitle,
|
||||
sectionSubtitle = containerSubtitle,
|
||||
onBack = { routes.navController.popBackStack() }
|
||||
@@ -280,13 +293,16 @@ class FeaturesRootSection : Routes.Route() {
|
||||
composable(SEARCH_FEATURE_ROUTE) { backStackEntry ->
|
||||
backStackEntry.arguments?.getString("keyword")?.let { keyword ->
|
||||
val properties = allProperties.filter {
|
||||
it.key.name.contains(keyword, ignoreCase = true) ||
|
||||
isSearchVisibleProperty(it.key) && (
|
||||
it.key.name.contains(keyword, ignoreCase = true) ||
|
||||
context.translation[it.key.propertyName()].contains(keyword, ignoreCase = true) ||
|
||||
context.translation[it.key.propertyDescription()].contains(keyword, ignoreCase = true)
|
||||
)
|
||||
}.map { PropertyPair(it.key, it.value) }
|
||||
|
||||
PropertiesView(
|
||||
properties = properties,
|
||||
stateKey = "${routeInfo.id}:search:$keyword",
|
||||
isSearchResults = true,
|
||||
searchKeyword = keyword,
|
||||
enableGlobalSearch = true,
|
||||
@@ -668,7 +684,7 @@ class FeaturesRootSection : Routes.Route() {
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Text(
|
||||
text = "$messageCount messages",
|
||||
text = translation.format("search_results_count", "count" to messageCount.toString()),
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White
|
||||
@@ -1014,7 +1030,7 @@ class FeaturesRootSection : Routes.Route() {
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = translation["search_button"] ?: "Search",
|
||||
text = translation["search_button"],
|
||||
color = Color(0xFFE0DCFF)
|
||||
)
|
||||
},
|
||||
@@ -1060,9 +1076,11 @@ class FeaturesRootSection : Routes.Route() {
|
||||
@Composable
|
||||
private fun SensitiveDataDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (exportSensitiveData: Boolean) -> Unit
|
||||
onConfirm: (exportSensitiveData: Boolean, includeSavedLocations: Boolean) -> Unit
|
||||
) {
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
val includeSavedLocations = remember { mutableStateOf(false) }
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = Color.White.copy(alpha = 0.06f),
|
||||
@@ -1100,12 +1118,36 @@ class FeaturesRootSection : Routes.Route() {
|
||||
color = PurrfectPalette.textSecondary,
|
||||
modifier = Modifier.padding(horizontal = 6.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = context.translation["include_saved_locations"],
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White
|
||||
)
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
Switch(
|
||||
checked = includeSavedLocations.value,
|
||||
onCheckedChange = {
|
||||
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
includeSavedLocations.value = it
|
||||
},
|
||||
colors = purrfectSwitchColors()
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)
|
||||
) {
|
||||
Button(
|
||||
onClick = { onConfirm(false) },
|
||||
onClick = { onConfirm(false, includeSavedLocations.value) },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White.copy(alpha = 0.08f),
|
||||
contentColor = Color.White
|
||||
@@ -1114,7 +1156,7 @@ class FeaturesRootSection : Routes.Route() {
|
||||
Text(context.translation["button.negative"])
|
||||
}
|
||||
Button(
|
||||
onClick = { onConfirm(true) },
|
||||
onClick = { onConfirm(true, includeSavedLocations.value) },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.32f),
|
||||
contentColor = Color.White
|
||||
@@ -1244,10 +1286,11 @@ class FeaturesRootSection : Routes.Route() {
|
||||
if (showExportDialog) {
|
||||
SensitiveDataDialog(
|
||||
onDismiss = { showExportDialog = false },
|
||||
onConfirm = { exportSensitiveData ->
|
||||
onConfirm = { exportSensitiveData, includeSavedLocations ->
|
||||
showExportDialog = false
|
||||
routes.configExportSummary.navigate {
|
||||
put("exportSensitiveData", exportSensitiveData.toString())
|
||||
put("includeSavedLocations", includeSavedLocations.toString())
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -1283,7 +1326,7 @@ class FeaturesRootSection : Routes.Route() {
|
||||
|
||||
val headerTitle = activeSectionTitle ?: translation["manager.routes.features"]
|
||||
val subtitleText = when {
|
||||
isSearchResults -> translation["search_button"] ?: "Search"
|
||||
isSearchResults -> translation["search_button"]
|
||||
!activeSectionSubtitle.isNullOrBlank() -> activeSectionSubtitle
|
||||
else -> translation["manager.sections.features.subtitle"] ?: ""
|
||||
}
|
||||
@@ -1337,7 +1380,7 @@ class FeaturesRootSection : Routes.Route() {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = translation["button.back"] ?: "Back",
|
||||
contentDescription = context.translation["common.back"],
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
@@ -1358,7 +1401,7 @@ class FeaturesRootSection : Routes.Route() {
|
||||
.weight(1f)
|
||||
.focusRequester(focusRequester),
|
||||
singleLine = true,
|
||||
placeholder = { Text(text = translation["search_button"] ?: "Search", color = Color(0xFFE0DCFF)) },
|
||||
placeholder = { Text(text = translation["search_button"], color = Color(0xFFE0DCFF)) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Search,
|
||||
@@ -1516,7 +1559,7 @@ class FeaturesRootSection : Routes.Route() {
|
||||
) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = null, tint = Color.White.copy(alpha = 0.85f))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(text = translation["clear_history"] ?: "Clear history", color = Color.White)
|
||||
Text(text = translation["clear_history"], color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1531,6 +1574,7 @@ class FeaturesRootSection : Routes.Route() {
|
||||
@Composable
|
||||
private fun PropertiesView(
|
||||
properties: List<PropertyPair<*>>,
|
||||
stateKey: String,
|
||||
isSearchResults: Boolean = false,
|
||||
activeSectionTitle: String? = null,
|
||||
activeSectionSubtitle: String? = null,
|
||||
@@ -1540,13 +1584,13 @@ class FeaturesRootSection : Routes.Route() {
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
var controlsHeight by remember { mutableStateOf(96.dp) }
|
||||
val listState = rememberLazyListState()
|
||||
val listState = rememberRouteLazyListState(stateKey)
|
||||
val sharedSearchHistory = remember { mutableStateListOf<String>().apply { addAll(loadSearchHistory()) } }
|
||||
var liveSearchQuery by rememberSaveable { mutableStateOf(searchKeyword.orEmpty()) }
|
||||
val isActiveSearch = isSearchResults || liveSearchQuery.isNotBlank()
|
||||
val globalSearchProperties = remember(enableGlobalSearch) {
|
||||
if (enableGlobalSearch) {
|
||||
allProperties.map { PropertyPair(it.key, it.value) }
|
||||
allProperties.filter { isSearchVisibleProperty(it.key) }.map { PropertyPair(it.key, it.value) }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
@@ -1645,6 +1689,7 @@ class FeaturesRootSection : Routes.Route() {
|
||||
@Composable
|
||||
private fun Container(
|
||||
configContainer: ConfigContainer,
|
||||
stateKey: String,
|
||||
sectionTitle: String? = null,
|
||||
sectionSubtitle: String? = null,
|
||||
searchKeyword: String? = null,
|
||||
@@ -1656,6 +1701,7 @@ class FeaturesRootSection : Routes.Route() {
|
||||
!it.key.params.flags.contains(ConfigFlag.HIDDEN)
|
||||
}
|
||||
},
|
||||
stateKey = stateKey,
|
||||
activeSectionTitle = sectionTitle,
|
||||
activeSectionSubtitle = sectionSubtitle,
|
||||
searchKeyword = searchKeyword,
|
||||
|
||||
@@ -38,6 +38,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.common.data.MessagingRuleType
|
||||
import me.eternal.purrfectsnap.ui.manager.rememberRouteScrollState
|
||||
import me.eternal.purrfectsnap.common.data.RuleState
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncUpdateDispatcher
|
||||
@@ -203,8 +204,8 @@ class ManageRuleFeature : Routes.Route() {
|
||||
title = translation["clear_list_button"],
|
||||
text = translation["dialog_clear_confirmation_text"],
|
||||
icon = Icons.Default.DeleteSweep,
|
||||
confirmButtonText = context.translation["clear"],
|
||||
dismissButtonText = context.translation["button.cancel"],
|
||||
confirmButtonText = translation["dialog_clear_confirm_button"],
|
||||
dismissButtonText = translation["dialog_clear_cancel_button"],
|
||||
onDismiss = { confirmationDialog = false },
|
||||
onConfirm = {
|
||||
context.database.clearRuleIds(currentRuleType.key)
|
||||
@@ -241,7 +242,7 @@ class ManageRuleFeature : Routes.Route() {
|
||||
.fillMaxSize()
|
||||
.padding(top = topBarHeight + 10.dp)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
.verticalScroll(rememberRouteScrollState(routeInfo.id)),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
val headerShape = RoundedCornerShape(22.dp)
|
||||
@@ -390,7 +391,7 @@ class ManageRuleFeature : Routes.Route() {
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(text = context.translation["clear"])
|
||||
Text(text = translation["dialog_clear_confirm_button"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,7 +315,7 @@ class HomeLogs : Routes.Route() {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, tint = Color.White)
|
||||
}
|
||||
Text(
|
||||
text = routeInfo.translatedKey?.value ?: translation["manager.routes.home_logs"] ?: "Logs",
|
||||
text = routeInfo.translatedKey?.value ?: translation["manager.routes.home_logs"],
|
||||
color = PurrfectPalette.textPrimary,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.ExtraBold
|
||||
@@ -412,7 +412,7 @@ class HomeLogs : Routes.Route() {
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Text(
|
||||
text = "Pull to refresh or trigger an action to see new entries.",
|
||||
text = translation["refresh_hint"],
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 13.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
|
||||
@@ -46,13 +46,13 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Help
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.BugReport
|
||||
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.Language
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
@@ -137,6 +137,7 @@ class HomeRootSection : Routes.Route() {
|
||||
override val translation by lazy { context.translation.getCategory("manager.sections.home") }
|
||||
|
||||
companion object {
|
||||
private const val QUICK_TILES_INITIALIZED_PREF = "quick_tiles_initialized"
|
||||
val cardMargin = 10.dp
|
||||
val pageBackgroundGradient = Brush.verticalGradient(
|
||||
listOf(
|
||||
@@ -408,7 +409,7 @@ class HomeRootSection : Routes.Route() {
|
||||
onUpdateAction: () -> Unit,
|
||||
channelLabel: String,
|
||||
isPurrAuraActive: Boolean,
|
||||
onWikiClick: () -> Unit,
|
||||
onWebsiteClick: () -> Unit,
|
||||
onTelegramClick: () -> Unit,
|
||||
onGithubClick: () -> Unit,
|
||||
authorName: String,
|
||||
@@ -649,15 +650,15 @@ class HomeRootSection : Routes.Route() {
|
||||
) {
|
||||
Button(
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = onWikiClick,
|
||||
onClick = onWebsiteClick,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White,
|
||||
contentColor = Color(0xFF1B152E)
|
||||
)
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.Help, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Icon(Icons.Filled.Language, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = translation["wiki_button"], maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(text = "Site", maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
OutlinedButton(
|
||||
modifier = Modifier.weight(1f),
|
||||
@@ -736,8 +737,25 @@ class HomeRootSection : Routes.Route() {
|
||||
val avenirNext = remember {
|
||||
FontFamily(Font(R.font.avenir_next_medium, FontWeight.Medium))
|
||||
}
|
||||
val selectedTiles = rememberAsyncMutableStateList(defaultValue = listOf()) {
|
||||
context.database.getQuickTiles().filter { it.isNotBlank() }
|
||||
val prefs = remember { context.sharedPreferences }
|
||||
val allQuickTileNames = remember(cards) { cards.keys.map { it.first } }
|
||||
val selectedTiles = rememberAsyncMutableStateList(defaultValue = allQuickTileNames) {
|
||||
val storedTiles = context.database.getQuickTiles().filter { it.isNotBlank() }
|
||||
val hasInitializedQuickTiles = prefs.getBoolean(QUICK_TILES_INITIALIZED_PREF, false)
|
||||
when {
|
||||
storedTiles.isNotEmpty() -> {
|
||||
if (!hasInitializedQuickTiles) {
|
||||
prefs.edit().putBoolean(QUICK_TILES_INITIALIZED_PREF, true).apply()
|
||||
}
|
||||
storedTiles
|
||||
}
|
||||
hasInitializedQuickTiles -> storedTiles
|
||||
else -> {
|
||||
context.database.setQuickTiles(allQuickTileNames)
|
||||
prefs.edit().putBoolean(QUICK_TILES_INITIALIZED_PREF, true).apply()
|
||||
allQuickTileNames
|
||||
}
|
||||
}
|
||||
}
|
||||
val updateChannel = context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable"
|
||||
val channelLabel = if (updateChannel == "prerelease") translation["channel_label_prerelease"] else translation["channel_label_stable"]
|
||||
@@ -952,9 +970,9 @@ class HomeRootSection : Routes.Route() {
|
||||
onUpdateAction = onUpdateButtonClick,
|
||||
channelLabel = channelLabel,
|
||||
isPurrAuraActive = isPurrAuraActive,
|
||||
onWikiClick = {
|
||||
onWebsiteClick = {
|
||||
context.androidContext.openLink(
|
||||
"https://github.com/particle-box/PurrfectSnap/wiki",
|
||||
"https://purrfectsnap.vercel.app/",
|
||||
context.translation["toast_open_link_failed"]
|
||||
)
|
||||
},
|
||||
@@ -1177,17 +1195,18 @@ class HomeRootSection : Routes.Route() {
|
||||
if (showChangelogDialog && latestUpdate != null) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showChangelogDialog = false },
|
||||
title = "Changelog",
|
||||
title = translation["changelog_dialog_title"],
|
||||
text = "",
|
||||
icon = Icons.Filled.Info,
|
||||
confirmButtonText = "Update",
|
||||
confirmButtonText = translation["changelog_dialog_update_button"],
|
||||
onConfirm = {
|
||||
showChangelogDialog = false
|
||||
handleUpdateAction()
|
||||
},
|
||||
dismissButtonText = "Cancel",
|
||||
dismissButtonText = translation["changelog_dialog_cancel_button"],
|
||||
onDismiss = { showChangelogDialog = false },
|
||||
confirmEnabled = !changelogLoading,
|
||||
showCloseButton = false,
|
||||
customContent = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -1210,7 +1229,7 @@ class HomeRootSection : Routes.Route() {
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Text(
|
||||
text = "Loading changelog…",
|
||||
text = translation["changelog_dialog_loading"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
@@ -1219,7 +1238,7 @@ class HomeRootSection : Routes.Route() {
|
||||
|
||||
changelogError != null -> {
|
||||
Text(
|
||||
text = changelogError ?: "Failed to load changelog",
|
||||
text = changelogError ?: translation["changelog_dialog_error"],
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
@@ -1227,7 +1246,7 @@ class HomeRootSection : Routes.Route() {
|
||||
|
||||
else -> {
|
||||
Text(
|
||||
text = changelogText ?: "Changelog not available",
|
||||
text = changelogText ?: translation["changelog_dialog_empty"],
|
||||
color = PurrfectPalette.textPrimary,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp
|
||||
@@ -1242,12 +1261,13 @@ class HomeRootSection : Routes.Route() {
|
||||
if (showAnnouncementsDialog) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showAnnouncementsDialog = false },
|
||||
title = "Announcements",
|
||||
title = translation["announcements_dialog_title"],
|
||||
text = "",
|
||||
icon = Icons.Filled.Info,
|
||||
confirmButtonText = "Close",
|
||||
confirmButtonText = translation["announcements_dialog_close_button"],
|
||||
onConfirm = { showAnnouncementsDialog = false },
|
||||
confirmEnabled = !announcementsLoading,
|
||||
showCloseButton = false,
|
||||
customContent = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -1270,7 +1290,7 @@ class HomeRootSection : Routes.Route() {
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Text(
|
||||
text = "Loading announcements...",
|
||||
text = translation["announcements_dialog_loading"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
@@ -1279,7 +1299,7 @@ class HomeRootSection : Routes.Route() {
|
||||
|
||||
announcementsError != null -> {
|
||||
Text(
|
||||
text = announcementsError ?: "Failed to load announcements",
|
||||
text = announcementsError ?: translation["announcements_dialog_error"],
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
@@ -1287,7 +1307,7 @@ class HomeRootSection : Routes.Route() {
|
||||
|
||||
else -> {
|
||||
Text(
|
||||
text = announcementsText ?: "Announcements not available",
|
||||
text = announcementsText ?: translation["announcements_dialog_empty"],
|
||||
color = PurrfectPalette.textPrimary,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp
|
||||
@@ -1311,6 +1331,7 @@ class HomeRootSection : Routes.Route() {
|
||||
newList.forEach { clearTileOffset(it) }
|
||||
selectedTiles.clear()
|
||||
selectedTiles.addAll(newList)
|
||||
prefs.edit().putBoolean(QUICK_TILES_INITIALIZED_PREF, true).apply()
|
||||
context.coroutineScope.launch {
|
||||
context.database.setQuickTiles(selectedTiles)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.DeleteSweep
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -51,6 +53,7 @@ import me.eternal.purrfectsnap.storage.getAllScopeNotes
|
||||
import me.eternal.purrfectsnap.storage.setAllScopeNotes
|
||||
import me.eternal.purrfectsnap.task.UpdateCheckWorker
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.setup.Requirements
|
||||
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
|
||||
@@ -143,11 +146,16 @@ class HomeSettings : Routes.Route() {
|
||||
sharedPreferences: SharedPreferences,
|
||||
key: String,
|
||||
text: String,
|
||||
defaultValue: Boolean = false
|
||||
defaultValue: Boolean = false,
|
||||
confirmDisableTitle: String? = null,
|
||||
confirmDisableText: String? = null
|
||||
) {
|
||||
val realKey = "debug_$key"
|
||||
var value by remember { mutableStateOf(sharedPreferences.getBoolean(realKey, defaultValue)) }
|
||||
var showDisableDialog by remember { mutableStateOf(false) }
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
val positiveLabel = context.translation["button.positive"]
|
||||
val negativeLabel = context.translation["button.negative"]
|
||||
|
||||
LaunchedEffect(realKey) {
|
||||
if (!sharedPreferences.contains(realKey)) {
|
||||
@@ -156,6 +164,24 @@ class HomeSettings : Routes.Route() {
|
||||
}
|
||||
}
|
||||
|
||||
if (showDisableDialog) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showDisableDialog = false },
|
||||
title = confirmDisableTitle ?: translation["reset_setup_dialog_title"],
|
||||
text = confirmDisableText.orEmpty(),
|
||||
icon = Icons.Filled.Warning,
|
||||
confirmButtonText = positiveLabel,
|
||||
dismissButtonText = negativeLabel,
|
||||
onConfirm = {
|
||||
value = false
|
||||
sharedPreferences.edit().putBoolean(realKey, false).apply()
|
||||
showDisableDialog = false
|
||||
},
|
||||
onDismiss = { showDisableDialog = false },
|
||||
showCloseButton = false
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -164,11 +190,15 @@ class HomeSettings : Routes.Route() {
|
||||
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
value = !value
|
||||
sharedPreferences
|
||||
.edit() {
|
||||
val nextValue = !value
|
||||
if (!nextValue && confirmDisableTitle != null) {
|
||||
showDisableDialog = true
|
||||
} else {
|
||||
value = nextValue
|
||||
sharedPreferences.edit() {
|
||||
putBoolean(realKey, value)
|
||||
}
|
||||
}
|
||||
},
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
@@ -283,11 +313,15 @@ class HomeSettings : Routes.Route() {
|
||||
val contextC = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val scrollState = rememberScrollState()
|
||||
val positiveLabel = context.translation["button.positive"]
|
||||
val negativeLabel = context.translation["button.negative"]
|
||||
val importLabel = context.translation["button.import"]
|
||||
val sharedButtonColors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White.copy(alpha = 0.12f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
val sharedOutlinedColors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White)
|
||||
var showResetSetupDialog by remember { mutableStateOf(false) }
|
||||
|
||||
@Composable
|
||||
fun GlassCard(
|
||||
@@ -343,6 +377,39 @@ class HomeSettings : Routes.Route() {
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
if (showResetSetupDialog) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showResetSetupDialog = false },
|
||||
title = translation["reset_setup_dialog_title"],
|
||||
text = translation["reset_setup_dialog_text"],
|
||||
icon = Icons.Filled.Warning,
|
||||
confirmButtonText = positiveLabel,
|
||||
dismissButtonText = negativeLabel,
|
||||
onConfirm = {
|
||||
showResetSetupDialog = false
|
||||
context.sharedPreferences.edit()
|
||||
.remove("setup_in_progress")
|
||||
.remove("setup_current_route")
|
||||
.remove("setup_skip_patch")
|
||||
.remove("setup_install_mode")
|
||||
.apply()
|
||||
|
||||
context.config.reset()
|
||||
context.config.writeConfig()
|
||||
|
||||
val intent = android.content.Intent(
|
||||
context.androidContext,
|
||||
me.eternal.purrfectsnap.ui.setup.SetupActivity::class.java
|
||||
)
|
||||
intent.flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||
android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
context.androidContext.startActivity(intent)
|
||||
routes.navController.popBackStack()
|
||||
},
|
||||
onDismiss = { showResetSetupDialog = false },
|
||||
showCloseButton = false
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
@@ -546,44 +613,26 @@ class HomeSettings : Routes.Route() {
|
||||
}
|
||||
|
||||
GlassCard {
|
||||
RowTitle(title = "Reset PurrfectSnap")
|
||||
RowTitle(title = translation["reset_setup_title"])
|
||||
ShiftedRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 55.dp)
|
||||
.clickable {
|
||||
// Clear setup progress and route back to SetupActivity
|
||||
context.sharedPreferences.edit()
|
||||
.remove("setup_in_progress")
|
||||
.remove("setup_current_route")
|
||||
.remove("setup_skip_patch")
|
||||
.remove("setup_install_mode")
|
||||
.apply()
|
||||
|
||||
// Clear config to defaults
|
||||
context.config.reset()
|
||||
context.config.writeConfig()
|
||||
|
||||
// Launch setup activity fresh
|
||||
val intent = android.content.Intent(context.androidContext, me.eternal.purrfectsnap.ui.setup.SetupActivity::class.java)
|
||||
intent.flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
context.androidContext.startActivity(intent)
|
||||
|
||||
// Close current manager activity
|
||||
routes.navController.popBackStack()
|
||||
showResetSetupDialog = true
|
||||
},
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Reset and restart setup",
|
||||
text = translation["reset_setup_action"],
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
lineHeight = 20.sp
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
|
||||
contentDescription = "Reset",
|
||||
contentDescription = translation["reset_setup_action"],
|
||||
modifier = Modifier.padding(end = 14.dp)
|
||||
)
|
||||
}
|
||||
@@ -697,7 +746,7 @@ class HomeSettings : Routes.Route() {
|
||||
colors = sharedButtonColors,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Text(text = "Import")
|
||||
Text(text = translation["import_button"])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -712,53 +761,49 @@ class HomeSettings : Routes.Route() {
|
||||
Text(translation["view_logger_history_button"])
|
||||
}
|
||||
if (showImportDialog) {
|
||||
AlertDialog(
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showImportDialog = false },
|
||||
title = { Text("Import message logger") },
|
||||
text = { Text("Importing will override your current message logger database. Continue?") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
showImportDialog = false
|
||||
runCatching {
|
||||
activityLauncherHelper.openFile("application/octet-stream") { uri ->
|
||||
runCatching {
|
||||
context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { inputStream ->
|
||||
context.messageLogger.databaseFile.outputStream().use { outputStream ->
|
||||
inputStream.copyTo(outputStream)
|
||||
}
|
||||
} ?: throw IllegalStateException("Unable to open selected file")
|
||||
storedMessagesCount = context.messageLogger.getStoredMessageCount()
|
||||
storedStoriesCount = context.messageLogger.getStoredStoriesCount()
|
||||
context.shortToast(translation["success_toast"])
|
||||
context.log.info("Imported message logger from $uri", "MessageLogger")
|
||||
}.onFailure {
|
||||
context.log.error("Failed to import message logger", it)
|
||||
context.longToast(
|
||||
translation.format(
|
||||
"import_failed_toast",
|
||||
"message" to (it.localizedMessage ?: it.message ?: "")
|
||||
)
|
||||
title = translation["message_logger_import_title"],
|
||||
text = translation["message_logger_import_text"],
|
||||
icon = Icons.Filled.Info,
|
||||
confirmButtonText = importLabel,
|
||||
dismissButtonText = context.translation["button.cancel"],
|
||||
onConfirm = {
|
||||
showImportDialog = false
|
||||
runCatching {
|
||||
activityLauncherHelper.openFile("application/octet-stream") { uri ->
|
||||
runCatching {
|
||||
context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { inputStream ->
|
||||
context.messageLogger.databaseFile.outputStream().use { outputStream ->
|
||||
inputStream.copyTo(outputStream)
|
||||
}
|
||||
} ?: throw IllegalStateException("Unable to open selected file")
|
||||
storedMessagesCount = context.messageLogger.getStoredMessageCount()
|
||||
storedStoriesCount = context.messageLogger.getStoredStoriesCount()
|
||||
context.shortToast(translation["success_toast"])
|
||||
context.log.info("Imported message logger from $uri", "MessageLogger")
|
||||
}.onFailure {
|
||||
context.log.error("Failed to import message logger", it)
|
||||
context.longToast(
|
||||
translation.format(
|
||||
"import_failed_toast",
|
||||
"message" to (it.localizedMessage ?: it.message ?: "")
|
||||
)
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to launch import picker", it)
|
||||
context.longToast(
|
||||
translation.format(
|
||||
"import_failed_toast",
|
||||
"message" to (it.localizedMessage ?: it.message ?: "")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}) {
|
||||
Text(translation["button.import"] ?: "Import")
|
||||
}.onFailure {
|
||||
context.log.error("Failed to launch import picker", it)
|
||||
context.longToast(
|
||||
translation.format(
|
||||
"import_failed_toast",
|
||||
"message" to (it.localizedMessage ?: it.message ?: "")
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showImportDialog = false }) {
|
||||
Text(translation["button.cancel"])
|
||||
}
|
||||
}
|
||||
onDismiss = { showImportDialog = false },
|
||||
showCloseButton = false
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -901,7 +946,9 @@ class HomeSettings : Routes.Route() {
|
||||
context.sharedPreferences,
|
||||
key = "test_mode",
|
||||
text = translation["test_mode_label"],
|
||||
defaultValue = true
|
||||
defaultValue = true,
|
||||
confirmDisableTitle = translation["purr_aura_disable_title"],
|
||||
confirmDisableText = translation["purr_aura_disable_text"]
|
||||
)
|
||||
PreferenceToggle(context.sharedPreferences, key = "disable_feature_loading", text = translation["disable_feature_loading_label"])
|
||||
PreferenceToggle(context.sharedPreferences, key = "disable_mapper", text = translation["disable_auto_mapper_label"])
|
||||
|
||||
@@ -58,6 +58,8 @@ import kotlin.math.roundToInt
|
||||
import kotlin.random.Random
|
||||
|
||||
class RetroGameScreen : Routes.Route() {
|
||||
override val translation by lazy { context.translation.getCategory("manager.sections.retro_flight") }
|
||||
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
||||
val gridWidth = 120
|
||||
val gridHeight = 160
|
||||
@@ -206,7 +208,7 @@ class RetroGameScreen : Routes.Route() {
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
FloatingTopBar(
|
||||
title = "Retro Flight",
|
||||
title = translation["title"],
|
||||
onBack = { routes.navController.popBackStack() }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
@@ -243,7 +245,7 @@ class RetroGameScreen : Routes.Route() {
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
) {
|
||||
Text(
|
||||
text = "GAME OVER",
|
||||
text = translation["game_over_label"],
|
||||
color = Color.White,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
@@ -258,7 +260,7 @@ class RetroGameScreen : Routes.Route() {
|
||||
),
|
||||
shape = RoundedCornerShape(6.dp)
|
||||
) {
|
||||
Text("RESTART", fontFamily = pixelFont, fontSize = 12.sp)
|
||||
Text(translation["restart_button"], fontFamily = pixelFont, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -293,7 +295,7 @@ class RetroGameScreen : Routes.Route() {
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = "LEFT",
|
||||
text = translation["left_button"],
|
||||
color = Color.White,
|
||||
fontSize = 14.sp,
|
||||
fontFamily = pixelFont,
|
||||
@@ -324,7 +326,7 @@ class RetroGameScreen : Routes.Route() {
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = "RIGHT",
|
||||
text = translation["right_button"],
|
||||
color = Color.White,
|
||||
fontSize = 14.sp,
|
||||
fontFamily = pixelFont,
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.location
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
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.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
@@ -16,6 +23,7 @@ import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.delay
|
||||
import me.eternal.purrfectsnap.bridge.location.LocationCoordinates
|
||||
import me.eternal.purrfectsnap.common.bridge.wrapper.LocaleWrapper
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.AlertDialogs
|
||||
|
||||
|
||||
@@ -38,54 +46,105 @@ fun AddCoordinatesDialog(
|
||||
|
||||
alertDialogs.DefaultDialogCard {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
val fieldColors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White.copy(alpha = 0.08f),
|
||||
unfocusedContainerColor = Color.White.copy(alpha = 0.05f),
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
focusedLabelColor = PurrfectPalette.textSecondary,
|
||||
unfocusedLabelColor = PurrfectPalette.textSecondary,
|
||||
cursorColor = PurrfectPalette.glowSecondary,
|
||||
focusedTextColor = Color.White,
|
||||
unfocusedTextColor = Color.White
|
||||
)
|
||||
Surface(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = Color.Transparent,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 12.dp
|
||||
) {
|
||||
Text(translation["save_coordinates_dialog_title"], fontSize = 20.sp, fontWeight = FontWeight.Bold)
|
||||
OutlinedTextField(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.focusRequester(focusRequester),
|
||||
value = savedName,
|
||||
onValueChange = { savedName = it },
|
||||
label = { Text(translation["saved_name_dialog_hint"]) }
|
||||
)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(200)
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = savedLatitude,
|
||||
onValueChange = { savedLatitude = it },
|
||||
label = { Text(translation["latitude_dialog_hint"]) }
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = savedLongitude,
|
||||
onValueChange = { savedLongitude = it },
|
||||
label = { Text(translation["longitude_dialog_hint"]) }
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 16.dp),
|
||||
horizontalArrangement = Arrangement.End
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.cardOverlayColor.copy(alpha = 0.98f),
|
||||
Color(0xFF1A143A).copy(alpha = 0.94f)
|
||||
)
|
||||
),
|
||||
RoundedCornerShape(24.dp)
|
||||
)
|
||||
.padding(18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
confirm(LocationCoordinates().apply {
|
||||
this.name = savedName.text
|
||||
this.latitude = savedLatitude.toDoubleOrNull() ?: 0.0
|
||||
this.longitude = savedLongitude.toDoubleOrNull() ?: 0.0
|
||||
})
|
||||
},
|
||||
enabled = savedName.text.isNotBlank() && savedLatitude.isNotBlank() && savedLongitude.isNotBlank()
|
||||
Text(
|
||||
text = translation["save_coordinates_dialog_title"],
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
color = Color.White
|
||||
)
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester),
|
||||
value = savedName,
|
||||
onValueChange = { savedName = it },
|
||||
label = { Text(translation["saved_name_dialog_hint"]) },
|
||||
colors = fieldColors,
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(200)
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = savedLatitude,
|
||||
onValueChange = { savedLatitude = it },
|
||||
label = { Text(translation["latitude_dialog_hint"]) },
|
||||
colors = fieldColors,
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
singleLine = true
|
||||
)
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = savedLongitude,
|
||||
onValueChange = { savedLongitude = it },
|
||||
label = { Text(translation["longitude_dialog_hint"]) },
|
||||
colors = fieldColors,
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 10.dp),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
Text(translation["save_dialog_button"])
|
||||
Button(
|
||||
onClick = {
|
||||
confirm(LocationCoordinates().apply {
|
||||
this.name = savedName.text
|
||||
this.latitude = savedLatitude.toDoubleOrNull() ?: 0.0
|
||||
this.longitude = savedLongitude.toDoubleOrNull() ?: 0.0
|
||||
})
|
||||
},
|
||||
enabled = savedName.text.isNotBlank() && savedLatitude.isNotBlank() && savedLongitude.isNotBlank(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.3f),
|
||||
contentColor = Color.White,
|
||||
disabledContainerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.16f),
|
||||
disabledContentColor = Color.White.copy(alpha = 0.6f)
|
||||
)
|
||||
) {
|
||||
Text(translation["save_dialog_button"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -127,7 +126,11 @@ class BetterLocationRoot : Routes.Route() {
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = "Lat ${friendLocation.latitude.toFloat()}, Lng ${friendLocation.longitude.toFloat()}",
|
||||
text = context.translation.format(
|
||||
"spoofed_coordinates_title",
|
||||
"latitude" to friendLocation.latitude.toFloat().toString(),
|
||||
"longitude" to friendLocation.longitude.toFloat().toString()
|
||||
),
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = Color.White.copy(alpha = 0.8f)
|
||||
@@ -217,16 +220,21 @@ class BetterLocationRoot : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThemedEditLocationButton(onClick: () -> Unit) {
|
||||
private fun CoordinateActionButton(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
description: String,
|
||||
accent: Color,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
FilledIconButton(
|
||||
modifier = Modifier.size(40.dp),
|
||||
onClick = onClick,
|
||||
modifier = Modifier.size(42.dp),
|
||||
colors = IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
contentColor = if (isSystemInDarkTheme()) Color.White else Color(0xFF151A1A),
|
||||
),
|
||||
onClick = onClick
|
||||
containerColor = accent.copy(alpha = 0.22f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Icon(Icons.Default.Edit, contentDescription = translation["edit_location_button_description"])
|
||||
Icon(icon, contentDescription = description)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,6 +242,8 @@ class BetterLocationRoot : Routes.Route() {
|
||||
val coordinatesProperty = remember {
|
||||
context.config.root.global.betterLocation.getPropertyPair("coordinates")
|
||||
}
|
||||
val providerProperty = remember { context.config.root.global.betterLocation.getPropertyPair("location_search_provider") }
|
||||
val apiKeyProperty = remember { context.config.root.global.betterLocation.getPropertyPair("google_maps_api_key") }
|
||||
|
||||
val updateDispatcher = rememberAsyncUpdateDispatcher()
|
||||
val savedCoordinates = rememberAsyncMutableStateList(
|
||||
@@ -245,10 +255,17 @@ class BetterLocationRoot : Routes.Route() {
|
||||
var showMap by remember { mutableStateOf(false) }
|
||||
var addSavedCoordinateDialog by remember { mutableStateOf(false) }
|
||||
var showTeleportDialog by remember { mutableStateOf(false) }
|
||||
var showProviderDialog by remember { mutableStateOf(false) }
|
||||
var showApiKeyDialog 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<*, *>) }
|
||||
var spoofedCoordinates by remember(showTeleportDialog, showMap) {
|
||||
mutableStateOf(
|
||||
(coordinatesProperty.value.getNullable() as? Pair<*, *>)
|
||||
?: (0.0 to 0.0)
|
||||
)
|
||||
}
|
||||
|
||||
fun addSavedCoordinate(id: Int?, locationCoordinates: LocationCoordinates, onSuccess: suspend (id: Int) -> Unit = {}) {
|
||||
context.coroutineScope.launch {
|
||||
@@ -271,6 +288,36 @@ class BetterLocationRoot : Routes.Route() {
|
||||
)
|
||||
}
|
||||
|
||||
var currentProvider by remember {
|
||||
mutableStateOf(context.config.root.global.betterLocation.locationSearchProvider.getNullable() ?: "osm")
|
||||
}
|
||||
var currentApiKey by remember {
|
||||
mutableStateOf(context.config.root.global.betterLocation.googleMapsApiKey.getNullable() ?: "")
|
||||
}
|
||||
|
||||
if (showProviderDialog) {
|
||||
me.eternal.purrfectsnap.ui.util.Dialog(onDismissRequest = {
|
||||
showProviderDialog = false
|
||||
context.config.writeConfig()
|
||||
currentProvider = context.config.root.global.betterLocation.locationSearchProvider.getNullable() ?: "osm"
|
||||
}) {
|
||||
alertDialogs.UniqueSelectionDialog(providerProperty)
|
||||
}
|
||||
}
|
||||
if (showApiKeyDialog) {
|
||||
me.eternal.purrfectsnap.ui.util.Dialog(onDismissRequest = {
|
||||
showApiKeyDialog = false
|
||||
context.config.writeConfig()
|
||||
currentApiKey = context.config.root.global.betterLocation.googleMapsApiKey.getNullable() ?: ""
|
||||
}) {
|
||||
alertDialogs.KeyboardInputDialog(apiKeyProperty) {
|
||||
showApiKeyDialog = false
|
||||
context.config.writeConfig()
|
||||
currentApiKey = context.config.root.global.betterLocation.googleMapsApiKey.getNullable() ?: ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -337,9 +384,16 @@ class BetterLocationRoot : Routes.Route() {
|
||||
)
|
||||
) {
|
||||
Box(modifier = Modifier.background(PurrfectPalette.cardOverlay)) {
|
||||
alertDialogs.ChooseLocationDialog(property = coordinatesProperty, marker, mapView, saveCoordinates = {
|
||||
addSavedCoordinateDialog = true
|
||||
}) {
|
||||
alertDialogs.ChooseLocationDialog(
|
||||
property = coordinatesProperty,
|
||||
marker = marker,
|
||||
mapView = mapView,
|
||||
locationSearchProvider = context.config.root.global.betterLocation.locationSearchProvider.getNullable() ?: "osm",
|
||||
googleMapsApiKey = context.config.root.global.betterLocation.googleMapsApiKey.getNullable() ?: "",
|
||||
saveCoordinates = {
|
||||
addSavedCoordinateDialog = true
|
||||
}
|
||||
) {
|
||||
showMap = false
|
||||
context.config.writeConfig()
|
||||
}
|
||||
@@ -395,6 +449,42 @@ class BetterLocationRoot : Routes.Route() {
|
||||
) {
|
||||
context.config.root.global.betterLocation.suspendLocationUpdates.set(it)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConfigSelector(text: String, value: String, onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(text = text, modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = value,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConfigInput(text: String, value: String, onClick: () -> Unit) {
|
||||
ConfigSelector(text, if (value.isNotEmpty()) "********" else translation["options.empty"], onClick)
|
||||
}
|
||||
|
||||
ConfigSelector(
|
||||
text = translation["location_search_provider_title"],
|
||||
value = translation["option_$currentProvider"]
|
||||
) { showProviderDialog = true }
|
||||
|
||||
if (currentProvider == "google_maps") {
|
||||
ConfigInput(
|
||||
text = translation["google_maps_api_key_title"],
|
||||
value = currentApiKey
|
||||
) { showApiKeyDialog = true }
|
||||
}
|
||||
}
|
||||
item {
|
||||
GlassPanel(
|
||||
@@ -588,15 +678,19 @@ class BetterLocationRoot : Routes.Route() {
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
}
|
||||
FilledIconButton(onClick = {
|
||||
CoordinateActionButton(
|
||||
icon = Icons.Default.Edit,
|
||||
description = translation["edit_icon_description"],
|
||||
accent = PurrfectPalette.glowPrimary
|
||||
) {
|
||||
showEditDialog = true
|
||||
}) {
|
||||
Icon(Icons.Default.Edit, contentDescription = translation["edit_icon_description"])
|
||||
}
|
||||
FilledIconButton(onClick = {
|
||||
CoordinateActionButton(
|
||||
icon = Icons.Default.DeleteOutline,
|
||||
description = translation["delete_icon_description"],
|
||||
accent = PurrfectPalette.glowSecondary
|
||||
) {
|
||||
showDeleteDialog = true
|
||||
}) {
|
||||
Icon(Icons.Default.DeleteOutline, contentDescription = translation["delete_icon_description"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,7 +255,7 @@ class ManageScriptReposSection : Routes.Route() {
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to add repository", it)
|
||||
context.shortToast(translation.format("add_repo_failed_toast", "message" to (it.message ?: "Unknown")))
|
||||
context.shortToast(translation.format("add_repo_failed_toast", "message" to (it.message ?: context.translation["common.unknown"])))
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
@@ -292,7 +292,7 @@ class ManageScriptReposSection : Routes.Route() {
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
FloatingTopBar(
|
||||
title = routeInfo.translatedKey?.value ?: (translation["title"] ?: "Repositories"),
|
||||
title = routeInfo.translatedKey?.value ?: translation["title"],
|
||||
onBack = { routes.navController.popBackStack() },
|
||||
modifier = Modifier
|
||||
.zIndex(2f)
|
||||
|
||||
@@ -162,7 +162,7 @@ fun ScriptCatalog(root: ScriptingRootSection) {
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
context.shortToast(translation.format("error", "message" to (e.localizedMessage ?: "Unknown")))
|
||||
context.shortToast(translation.format("error", "message" to (e.localizedMessage ?: context.translation["common.unknown"])))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,8 +206,45 @@ fun ScriptCatalog(root: ScriptingRootSection) {
|
||||
) {
|
||||
item {
|
||||
if (isLoading) {
|
||||
Box(modifier = Modifier.fillMaxWidth().padding(8.dp), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(10.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = Color.White.copy(alpha = 0.06f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.30f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = Color.White
|
||||
)
|
||||
Text(
|
||||
text = translation["loading"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (allScripts.isEmpty() && repositories.isNotEmpty()) {
|
||||
Box(
|
||||
@@ -311,7 +348,7 @@ fun ScriptCatalog(root: ScriptingRootSection) {
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Text(
|
||||
text = translation.format("version", "version" to (entry.version ?: "N/A")),
|
||||
text = translation.format("version", "version" to (entry.version ?: context.translation["common.not_available"])),
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
|
||||
@@ -140,7 +140,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
return@launch
|
||||
}.onFailure {
|
||||
context.log.error("Failed to import script", it)
|
||||
context.shortToast(translation.format("import_failed", "message" to (it.message ?: "Unknown")))
|
||||
context.shortToast(translation.format("import_failed", "message" to (it.message ?: context.translation["common.unknown"])))
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
@@ -431,7 +431,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
|
||||
@Composable
|
||||
private fun SelectFolderButton(onClick: () -> Unit) {
|
||||
val label = translation.getOrNull("select_folder_button") ?: "Select folder"
|
||||
val label = translation["select_folder_button"]
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -439,13 +439,13 @@ class ScriptingRootSection : Routes.Route() {
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(68.dp),
|
||||
modifier = Modifier.size(78.dp),
|
||||
shape = CircleShape,
|
||||
color = Color.White.copy(alpha = 0.1f),
|
||||
color = PurrfectPalette.cardOverlayColor.copy(alpha = 0.9f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 10.dp,
|
||||
shadowElevation = 14.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
1.5.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.7f),
|
||||
@@ -456,17 +456,18 @@ class ScriptingRootSection : Routes.Route() {
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(6.dp)
|
||||
.size(66.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
Brush.radialGradient(
|
||||
colors = listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.335f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.25f),
|
||||
Color.Transparent
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.42f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.34f)
|
||||
)
|
||||
)
|
||||
)
|
||||
.border(1.dp, Color.White.copy(alpha = 0.14f), CircleShape)
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
@@ -474,7 +475,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
|
||||
contentDescription = label,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(32.dp)
|
||||
modifier = Modifier.size(34.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -627,7 +628,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(260.dp),
|
||||
.heightIn(min = 260.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Surface(
|
||||
@@ -743,7 +744,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
title = context.translation["manager.dialogs.scripting_warning.title"],
|
||||
text = context.translation["manager.dialogs.scripting_warning.content"],
|
||||
icon = Icons.Default.Warning,
|
||||
confirmButtonText = translation["button.ok"] ?: "OK",
|
||||
confirmButtonText = translation["button.ok"],
|
||||
onConfirm = { if (timeout == 0) scriptingWarning = false },
|
||||
loading = timeout > 0,
|
||||
showCloseButton = false,
|
||||
@@ -875,7 +876,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = translation["manager.routes.scripts"] ?: "Scripts",
|
||||
text = translation["manager.routes.scripts"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp
|
||||
|
||||
@@ -375,8 +375,7 @@ class AddFriendDialog(
|
||||
enabled = !allFriendsSelected
|
||||
) {
|
||||
Text(
|
||||
text = context.translation["manager.dialogs.messaging_action.select_all_button"]
|
||||
?: "Select All",
|
||||
text = context.translation["manager.dialogs.messaging_action.select_all_button"],
|
||||
color = if (allFriendsSelected) {
|
||||
Color.White.copy(alpha = 0.45f)
|
||||
} else {
|
||||
|
||||
@@ -91,7 +91,7 @@ class ManageScope: Routes.Route() {
|
||||
title = translation.format("delete_scope_confirm_dialog_title", "scope" to context.translation["scopes.${scope.key}"]),
|
||||
text = "",
|
||||
icon = Icons.Rounded.DeleteForever,
|
||||
confirmButtonText = translation["delete_button"] ?: "Delete",
|
||||
confirmButtonText = translation["delete_button"],
|
||||
dismissButtonText = context.translation["button.cancel"],
|
||||
onDismiss = { deleteConfirmDialog = false },
|
||||
onConfirm = {
|
||||
@@ -109,7 +109,7 @@ class ManageScope: Routes.Route() {
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
FloatingTopBar(
|
||||
title = titleText ?: "Manage",
|
||||
title = titleText ?: translation["manage_scope_title"],
|
||||
onBack = { routes.navController.popBackStack() },
|
||||
actions = {
|
||||
IconButton(onClick = { deleteConfirmDialog = true }) {
|
||||
|
||||
@@ -63,7 +63,7 @@ import me.eternal.purrfectsnap.ui.util.Dialog
|
||||
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
|
||||
|
||||
class MessagingPreview: Routes.Route() {
|
||||
override val translation by lazy { context.translation.getCategory("manager.sections.social.messaging_preview.messaging_preview") }
|
||||
override val translation by lazy { context.translation.getCategory("manager.sections.social.messaging_preview") }
|
||||
private lateinit var coroutineScope: CoroutineScope
|
||||
private lateinit var previewScrollState: LazyListState
|
||||
|
||||
@@ -79,21 +79,6 @@ class MessagingPreview: Routes.Route() {
|
||||
else selectedMessages.add(messageId)
|
||||
}
|
||||
|
||||
private fun tr(key: String, fallback: String? = null): String {
|
||||
fun normalizeCandidate(candidate: String?): String? {
|
||||
if (candidate.isNullOrBlank()) return null
|
||||
if (candidate == key) return null
|
||||
if (candidate.endsWith(".$key")) return null
|
||||
return candidate
|
||||
}
|
||||
|
||||
return normalizeCandidate(translation.getOrNull(key))
|
||||
?: normalizeCandidate(context.translation.getOrNull("manager.sections.social.messaging_preview.$key"))
|
||||
?: normalizeCandidate(context.translation.getOrNull("manager.social.messaging_preview.$key"))
|
||||
?: fallback
|
||||
?: key
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionsSheetItem(
|
||||
title: String,
|
||||
@@ -338,20 +323,20 @@ class MessagingPreview: Routes.Route() {
|
||||
|
||||
val senderDisplayName by rememberAsyncMutableState<String?>(null, keys = arrayOf(senderId, myUserId, scope.key, scopeId, friendDisplayName)) {
|
||||
when {
|
||||
senderId == null -> "Unknown"
|
||||
senderId == myUserId -> "You"
|
||||
senderId == null -> translation["sender_unknown"]
|
||||
senderId == myUserId -> translation["sender_you"]
|
||||
scope == SocialScope.FRIEND -> friendDisplayName
|
||||
?: context.database.getFriendInfo(scopeId)?.displayName
|
||||
?: context.database.getFriendInfo(scopeId)?.mutableUsername
|
||||
?: "Friend"
|
||||
?: translation["sender_friend"]
|
||||
else -> context.database.getFriendInfo(senderId)?.displayName
|
||||
?: context.database.getFriendInfo(senderId)?.mutableUsername
|
||||
?: "Unknown"
|
||||
?: translation["sender_unknown"]
|
||||
}
|
||||
}
|
||||
|
||||
val contentTypeLabel = remember(contentType) {
|
||||
contentType?.let { contentTypeTranslation.getOrNull(it.name) ?: it.name } ?: "Unknown"
|
||||
contentType?.let { contentTypeTranslation.getOrNull(it.name) ?: it.name } ?: translation["sender_unknown"]
|
||||
}
|
||||
val bodyText = remember(message.contentType) { messageReader.getString(2, 1)?.trim().orEmpty() }
|
||||
|
||||
@@ -408,7 +393,7 @@ class MessagingPreview: Routes.Route() {
|
||||
horizontalAlignment = if (isMine) Alignment.End else Alignment.Start
|
||||
) {
|
||||
Text(
|
||||
text = senderDisplayName ?: "Unknown",
|
||||
text = senderDisplayName ?: translation["sender_unknown"],
|
||||
color = Color.White.copy(alpha = 0.82f),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
@@ -487,7 +472,7 @@ class MessagingPreview: Routes.Route() {
|
||||
.padding(40.dp),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(tr("no_message_hint", "No messages"), color = PurrfectPalette.textSecondary)
|
||||
Text(translation["no_message_hint"], color = PurrfectPalette.textSecondary)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
@@ -564,7 +549,7 @@ class MessagingPreview: Routes.Route() {
|
||||
translation.format("processed_message_toast", "count" to processMessageCount.intValue.toString())
|
||||
} ?: translation.getOrNull("processed_messages_toast")?.let {
|
||||
translation.format("processed_messages_toast", "count" to processMessageCount.intValue.toString())
|
||||
} ?: "Processed ${processMessageCount.intValue} messages"
|
||||
} ?: translation.format("processed_messages_toast", "count" to processMessageCount.intValue.toString())
|
||||
context.longToast(toastText)
|
||||
}
|
||||
}
|
||||
@@ -579,7 +564,7 @@ class MessagingPreview: Routes.Route() {
|
||||
context.longToast(
|
||||
translation.getOrNull("bridge_connection_error")
|
||||
?: translation.getOrNull("bridge_connection_failed")
|
||||
?: "Failed to connect to bridge"
|
||||
?: translation["bridge_connection_error"]
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -640,7 +625,7 @@ class MessagingPreview: Routes.Route() {
|
||||
) {
|
||||
val processedText = translation.getOrNull("processed_messages_text")?.let {
|
||||
translation.format("processed_messages_text", "count" to processMessageCount.intValue.toString())
|
||||
} ?: "Processed ${processMessageCount.intValue}"
|
||||
} ?: translation.format("processed_messages_text", "count" to processMessageCount.intValue.toString())
|
||||
Text(processedText)
|
||||
if (activeTask?.hasFixedGoal() == true) {
|
||||
LinearProgressIndicator(
|
||||
@@ -679,7 +664,7 @@ class MessagingPreview: Routes.Route() {
|
||||
}.onFailure {
|
||||
context.log.error("Failed to fetch messages", it)
|
||||
context.shortToast(
|
||||
translation.getOrNull("message_fetch_failed") ?: "Failed to fetch messages"
|
||||
translation.getOrNull("message_fetch_failed") ?: translation["message_fetch_failed"]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -710,7 +695,7 @@ class MessagingPreview: Routes.Route() {
|
||||
fetchNewMessages()
|
||||
}.onFailure {
|
||||
context.longToast(
|
||||
translation.getOrNull("bridge_init_failed") ?: "Failed to initialize messaging bridge"
|
||||
translation.getOrNull("bridge_init_failed") ?: translation["bridge_init_failed"]
|
||||
)
|
||||
context.log.error("Failed to initialize messaging bridge", it)
|
||||
}
|
||||
@@ -751,11 +736,11 @@ class MessagingPreview: Routes.Route() {
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
FloatingTopBar(
|
||||
title = titleText ?: translation["title"] ?: "Preview",
|
||||
title = titleText ?: translation["title"],
|
||||
subtitle = if (selectedMessages.isNotEmpty()) {
|
||||
"${selectedMessages.size} selected"
|
||||
} else {
|
||||
tr("subtitle", "Hold to select")
|
||||
translation["subtitle"]
|
||||
.substringBefore("•")
|
||||
.substringBefore("·")
|
||||
.substringBefore("|")
|
||||
@@ -798,7 +783,7 @@ class MessagingPreview: Routes.Route() {
|
||||
Text(
|
||||
translation.getOrNull("bridge_connection_error")
|
||||
?: translation.getOrNull("bridge_connection_failed")
|
||||
?: "Failed to connect to bridge",
|
||||
?: translation["bridge_connection_error"],
|
||||
modifier = Modifier.padding(16.dp),
|
||||
color = Color.White
|
||||
)
|
||||
@@ -831,7 +816,7 @@ class MessagingPreview: Routes.Route() {
|
||||
val selectionSubtitle = if (hasSelection) {
|
||||
"${selectedMessages.size} selected"
|
||||
} else {
|
||||
"Choose message types"
|
||||
translation["choose_message_types_subtitle"]
|
||||
}
|
||||
|
||||
Column(
|
||||
@@ -862,7 +847,7 @@ class MessagingPreview: Routes.Route() {
|
||||
)
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Text(
|
||||
text = tr("actions_title", "Conversation Actions"),
|
||||
text = translation["actions_title"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp,
|
||||
@@ -886,31 +871,31 @@ class MessagingPreview: Routes.Route() {
|
||||
val deleteKey = if (hasSelection) "delete_selection_option" else "delete_all_option"
|
||||
|
||||
ActionsSheetItem(
|
||||
title = tr(saveKey, if (hasSelection) "Save Selection" else "Save All"),
|
||||
subtitle = if (hasSelection) "Save selected messages" else "Save by content type",
|
||||
title = translation[saveKey],
|
||||
subtitle = if (hasSelection) translation["save_selected_messages_subtitle"] else translation["save_by_content_type_subtitle"],
|
||||
icon = Icons.Rounded.BookmarkAdded
|
||||
) {
|
||||
launchMessagingTask(MessagingTaskType.SAVE)
|
||||
if (hasSelection) runCurrentTask() else selectConstraintsDialog = true
|
||||
}
|
||||
ActionsSheetItem(
|
||||
title = tr(unsaveKey, if (hasSelection) "Unsave Selection" else "Unsave All"),
|
||||
subtitle = if (hasSelection) "Unsave selected messages" else "Unsave by content type",
|
||||
title = translation[unsaveKey],
|
||||
subtitle = if (hasSelection) translation["unsave_selected_messages_subtitle"] else translation["unsave_by_content_type_subtitle"],
|
||||
icon = Icons.Rounded.BookmarkBorder
|
||||
) {
|
||||
launchMessagingTask(MessagingTaskType.UNSAVE)
|
||||
if (hasSelection) runCurrentTask() else selectConstraintsDialog = true
|
||||
}
|
||||
ActionsSheetItem(
|
||||
title = tr(markKey, if (hasSelection) "Mark selected as seen" else "Mark all as seen"),
|
||||
subtitle = "Marks snaps as seen",
|
||||
title = translation[markKey],
|
||||
subtitle = translation["mark_as_seen_subtitle"],
|
||||
icon = Icons.Rounded.RemoveRedEye
|
||||
) {
|
||||
if (messagingBridge == null) {
|
||||
context.longToast(
|
||||
translation.getOrNull("bridge_connection_error")
|
||||
?: translation.getOrNull("bridge_connection_failed")
|
||||
?: "Failed to connect to bridge"
|
||||
?: translation["bridge_connection_error"]
|
||||
)
|
||||
return@ActionsSheetItem
|
||||
}
|
||||
@@ -924,8 +909,8 @@ class MessagingPreview: Routes.Route() {
|
||||
runCurrentTask()
|
||||
}
|
||||
ActionsSheetItem(
|
||||
title = tr(deleteKey, if (hasSelection) "Delete Selection" else "Delete All"),
|
||||
subtitle = if (hasSelection) "Delete selected messages" else "Delete by content type",
|
||||
title = translation[deleteKey],
|
||||
subtitle = if (hasSelection) translation["delete_selected_messages_subtitle"] else translation["delete_by_content_type_subtitle"],
|
||||
icon = Icons.Rounded.DeleteForever,
|
||||
danger = true
|
||||
) {
|
||||
@@ -933,7 +918,7 @@ class MessagingPreview: Routes.Route() {
|
||||
context.longToast(
|
||||
translation.getOrNull("bridge_connection_error")
|
||||
?: translation.getOrNull("bridge_connection_failed")
|
||||
?: "Failed to connect to bridge"
|
||||
?: translation["bridge_connection_error"]
|
||||
)
|
||||
return@ActionsSheetItem
|
||||
}
|
||||
|
||||
@@ -37,11 +37,13 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.R
|
||||
import me.eternal.purrfectsnap.common.ReceiversConfig
|
||||
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
|
||||
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
|
||||
import me.eternal.purrfectsnap.common.data.SocialScope
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie
|
||||
import me.eternal.purrfectsnap.common.util.snap.SnapWidgetBroadcastReceiverHelper
|
||||
import me.eternal.purrfectsnap.storage.*
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
@@ -58,6 +60,16 @@ class SocialRootSection : Routes.Route() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestLatestSnapshot() {
|
||||
runCatching {
|
||||
context.androidContext.sendBroadcast(
|
||||
SnapWidgetBroadcastReceiverHelper.create(ReceiversConfig.BRIDGE_SYNC_ACTION) {}
|
||||
)
|
||||
}.onFailure {
|
||||
context.log.error("Failed to request latest social snapshot", it)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScopeList(
|
||||
scope: SocialScope,
|
||||
@@ -196,7 +208,17 @@ class SocialRootSection : Routes.Route() {
|
||||
var searchActive by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
context.database.receiveMessagingDataCallback = { friends, groups ->
|
||||
friendList = friends
|
||||
groupList = groups
|
||||
}
|
||||
updateScopeLists()
|
||||
requestLatestSnapshot()
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
context.database.receiveMessagingDataCallback = { _, _ -> }
|
||||
}
|
||||
}
|
||||
val normalizedQuery = remember(searchQuery) { searchQuery.trim() }
|
||||
val filteredFriends = remember(friendList, normalizedQuery) {
|
||||
@@ -237,7 +259,7 @@ class SocialRootSection : Routes.Route() {
|
||||
}
|
||||
)
|
||||
if (searchActive) {
|
||||
val searchHint = context.translation["manager.dialogs.add_friend.search_hint"] ?: "Search"
|
||||
val searchHint = context.translation["manager.dialogs.add_friend.search_hint"]
|
||||
val searchShape = RoundedCornerShape(18.dp)
|
||||
val searchBorder = Brush.linearGradient(
|
||||
listOf(
|
||||
@@ -520,7 +542,7 @@ class SocialRootSection : Routes.Route() {
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = translation["manager.routes.social"] ?: "Social",
|
||||
text = translation["manager.routes.social"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp
|
||||
@@ -631,7 +653,7 @@ class SocialRootSection : Routes.Route() {
|
||||
fontSize = 15.sp
|
||||
)
|
||||
Text(
|
||||
text = translation["social_empty_hint"] ?: "Tap the + button to sync friends or groups.",
|
||||
text = translation["social_empty_hint"],
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
|
||||
@@ -148,7 +148,7 @@ class FriendTrackerCatalog : Routes.Route() {
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(translation["manage_repos_description"] ?: (context.translation["manager.routes.manage_friend_tracker_repos"] ?: "Manage repositories"))
|
||||
Text(translation["manage_repos_description"] ?: context.translation["manager.routes.manage_friend_tracker_repos"])
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -176,7 +176,7 @@ class FriendTrackerCatalog : Routes.Route() {
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
context.shortToast(translation.format("error", "message" to (e.localizedMessage ?: "Unknown")))
|
||||
context.shortToast(translation.format("error", "message" to (e.localizedMessage ?: context.translation["common.unknown"])))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -341,7 +341,7 @@ class FriendTrackerCatalog : Routes.Route() {
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
FloatingTopBar(
|
||||
title = translation["title"] ?: (routeInfo.translatedKey?.value ?: "Catalog"),
|
||||
title = translation["title"],
|
||||
onBack = { routes.navController.popBackStack() },
|
||||
actions = {
|
||||
IconButton(onClick = { routes.manageFriendTrackerRepos.navigate() }) {
|
||||
|
||||
@@ -115,7 +115,7 @@ class FriendTrackerConfigExportScreen : Routes.Route() {
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
context.longToast(translation.format("export_failed_toast", "message" to (it.message ?: "Unknown")))
|
||||
context.longToast(translation.format("export_failed_toast", "message" to (it.message ?: context.translation["common.unknown"])))
|
||||
}
|
||||
}
|
||||
}) {
|
||||
|
||||
@@ -78,7 +78,7 @@ class FriendTrackerConfigImportScreen : Routes.Route() {
|
||||
routes.onRuleImported?.invoke()
|
||||
routes.navController.popBackStack()
|
||||
}.onFailure {
|
||||
context.longToast(translation.format("import_failed_toast", "message" to (it.message ?: "Unknown")))
|
||||
context.longToast(translation.format("import_failed_toast", "message" to (it.message ?: context.translation["common.unknown"])))
|
||||
}
|
||||
}) {
|
||||
Text(translation["confirm_button"])
|
||||
|
||||
@@ -245,7 +245,7 @@ class ManageFriendTrackerReposSection: Routes.Route() {
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to add repository", it)
|
||||
context.shortToast(translation.format("add_repo_failed_toast", "message" to (it.message ?: "Unknown")))
|
||||
context.shortToast(translation.format("add_repo_failed_toast", "message" to (it.message ?: context.translation["common.unknown"])))
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
@@ -282,7 +282,7 @@ class ManageFriendTrackerReposSection: Routes.Route() {
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
FloatingTopBar(
|
||||
title = routeInfo.translatedKey?.value ?: (translation["title"] ?: "Repositories"),
|
||||
title = routeInfo.translatedKey?.value ?: translation["title"],
|
||||
onBack = { routes.navController.popBackStack() },
|
||||
modifier = Modifier
|
||||
.zIndex(2f)
|
||||
|
||||
@@ -16,10 +16,14 @@ 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)
|
||||
val authorLabel = context.translation["tracker_author_label"]
|
||||
val enabledLabel = context.translation["tracker_enabled_label"]
|
||||
val enabledValue = context.translation["tracker_enabled_value"]
|
||||
val disabledValue = context.translation["tracker_disabled_value"]
|
||||
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))
|
||||
features.add(ImportedFeature(rule.name, authorLabel, "author", rule.author ?: context.context.translation["common.unknown"], 0))
|
||||
features.add(ImportedFeature(rule.name, enabledLabel, "enabled", if (rule.enabled) enabledValue else disabledValue, 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))
|
||||
}
|
||||
@@ -30,7 +34,7 @@ class TrackerConfigParser(private val context: Routes.Route) {
|
||||
|
||||
fun parseValue(featureKey: String, value: Any): Any {
|
||||
return when (value) {
|
||||
is Boolean -> if (value) "Enabled" else "Disabled"
|
||||
is Boolean -> if (value) context.translation["tracker_enabled_value"] else context.translation["tracker_disabled_value"]
|
||||
is JSONArray -> {
|
||||
val list = mutableListOf<String>()
|
||||
for (i in 0 until value.length()) {
|
||||
|
||||
@@ -57,8 +57,10 @@ import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.osmdroid.config.Configuration
|
||||
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
|
||||
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||
import org.osmdroid.util.GeoPoint
|
||||
import org.osmdroid.util.MapTileIndex
|
||||
import org.osmdroid.views.CustomZoomButtonsController
|
||||
import org.osmdroid.views.MapView
|
||||
import org.osmdroid.views.overlay.Marker
|
||||
@@ -497,7 +499,7 @@ class AlertDialogs(
|
||||
currentColor = null
|
||||
}
|
||||
},
|
||||
label = { Text(text = "Hex Color") },
|
||||
label = { Text(text = translation["dialogs.hex_color_label"]) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(10.dp),
|
||||
@@ -592,6 +594,8 @@ class AlertDialogs(
|
||||
property: PropertyPair<*>,
|
||||
marker: MutableState<Marker?> = remember { mutableStateOf(null) },
|
||||
mapView: MutableState<MapView?> = remember { mutableStateOf(null) },
|
||||
locationSearchProvider: String = "osm",
|
||||
googleMapsApiKey: String = "",
|
||||
saveCoordinates: (() -> Unit)? = null,
|
||||
dismiss: () -> Unit = {}
|
||||
) {
|
||||
@@ -611,7 +615,20 @@ class AlertDialogs(
|
||||
MapView(context).apply {
|
||||
setMultiTouchControls(true)
|
||||
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
|
||||
setTileSource(TileSourceFactory.MAPNIK)
|
||||
val tileSource = if (locationSearchProvider == "google_maps") {
|
||||
object : OnlineTileSourceBase(
|
||||
"GoogleMaps",
|
||||
0, 19, 256, ".png",
|
||||
arrayOf("https://mt0.google.com/vt/lyrs=m", "https://mt1.google.com/vt/lyrs=m", "https://mt2.google.com/vt/lyrs=m", "https://mt3.google.com/vt/lyrs=m")
|
||||
) {
|
||||
override fun getTileURLString(pMapTileIndex: Long): String {
|
||||
return baseUrl + "&x=" + MapTileIndex.getX(pMapTileIndex) + "&y=" + MapTileIndex.getY(pMapTileIndex) + "&z=" + MapTileIndex.getZoom(pMapTileIndex)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
TileSourceFactory.MAPNIK
|
||||
}
|
||||
setTileSource(tileSource)
|
||||
|
||||
val startPoint = GeoPoint(coordinates.first, coordinates.second)
|
||||
controller.setZoom(10.0)
|
||||
@@ -686,28 +703,56 @@ class AlertDialogs(
|
||||
val resultsScrollState = rememberScrollState()
|
||||
|
||||
suspend fun search() {
|
||||
okHttpClient.newCall(Request.Builder()
|
||||
.url("https://nominatim.openstreetmap.org/search".toUri().buildUpon().appendQueryParameter("q", locationName).appendQueryParameter("format", "jsonv2").build().toString())
|
||||
.header("User-Agent", Constants.USER_AGENT)
|
||||
.build()
|
||||
).await().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return@use
|
||||
if (locationSearchProvider == "google_maps") {
|
||||
// Google Maps Search
|
||||
okHttpClient.newCall(Request.Builder()
|
||||
.url("https://maps.googleapis.com/maps/api/geocode/json".toUri().buildUpon()
|
||||
.appendQueryParameter("address", locationName)
|
||||
.appendQueryParameter("key", googleMapsApiKey)
|
||||
.build().toString())
|
||||
.build()
|
||||
).await().use { response ->
|
||||
if (!response.isSuccessful) return@use
|
||||
runCatching {
|
||||
val jsonResponse = JsonParser.parseString(response.body?.string() ?: "{}").asJsonObject
|
||||
if (jsonResponse.has("results")) {
|
||||
val results = jsonResponse.getAsJsonArray("results")
|
||||
addressResults = results.take(5).map { jsonElement ->
|
||||
val result = jsonElement.asJsonObject
|
||||
val geometry = result.getAsJsonObject("geometry").getAsJsonObject("location")
|
||||
Triple(
|
||||
result.get("formatted_address").asString,
|
||||
geometry.get("lat").asString,
|
||||
geometry.get("lng").asString
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runCatching {
|
||||
val body = JsonParser.parseString(response.body?.string() ?: "[]").asJsonArray
|
||||
addressResults = body.take(5).map { jsonElement ->
|
||||
val jsonObject = jsonElement.asJsonObject
|
||||
Triple(
|
||||
jsonObject.get("display_name").asString,
|
||||
jsonObject.get("lat").asString,
|
||||
jsonObject.get("lon").asString
|
||||
)
|
||||
} else {
|
||||
// OSM Nominatim Search (Existing Logic)
|
||||
okHttpClient.newCall(Request.Builder()
|
||||
.url("https://nominatim.openstreetmap.org/search".toUri().buildUpon()
|
||||
.appendQueryParameter("q", locationName)
|
||||
.appendQueryParameter("format", "jsonv2")
|
||||
.build().toString())
|
||||
.header("User-Agent", Constants.OSM_USER_AGENT)
|
||||
.build()
|
||||
).await().use { response ->
|
||||
if (!response.isSuccessful) return@use
|
||||
runCatching {
|
||||
val body = JsonParser.parseString(response.body?.string() ?: "[]").asJsonArray
|
||||
addressResults = body.take(5).map { jsonElement ->
|
||||
val jsonObject = jsonElement.asJsonObject
|
||||
Triple(
|
||||
jsonObject.get("display_name").asString,
|
||||
jsonObject.get("lat").asString,
|
||||
jsonObject.get("lon").asString
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
searchJob = null
|
||||
}
|
||||
|
||||
@@ -771,7 +816,7 @@ class AlertDialogs(
|
||||
color = Color.White
|
||||
)
|
||||
Text(
|
||||
text = "Search or tap on the map",
|
||||
text = betterLocationTranslation["search_or_tap_map_hint"],
|
||||
fontSize = 12.sp,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
@@ -825,11 +870,11 @@ class AlertDialogs(
|
||||
search()
|
||||
}
|
||||
},
|
||||
placeholder = { Text(text = "Search location...") },
|
||||
placeholder = { Text(text = betterLocationTranslation["search_location_placeholder"]) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Search,
|
||||
contentDescription = "Search",
|
||||
contentDescription = betterLocationTranslation["search_icon_description"],
|
||||
tint = Color.White
|
||||
)
|
||||
},
|
||||
@@ -949,7 +994,7 @@ class AlertDialogs(
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = "Searching...",
|
||||
text = betterLocationTranslation["searching_label"],
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White
|
||||
)
|
||||
@@ -1097,7 +1142,7 @@ class AlertDialogs(
|
||||
.padding(horizontal = 6.dp),
|
||||
value = lat.value,
|
||||
onValueChange = { lat.value = it },
|
||||
label = { Text(text = "Latitude") },
|
||||
label = { Text(text = translation["latitude_dialog_hint"]) },
|
||||
leadingIcon = { Icon(Icons.Filled.MyLocation, contentDescription = null) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
@@ -1117,7 +1162,7 @@ class AlertDialogs(
|
||||
.padding(horizontal = 6.dp),
|
||||
value = lon.value,
|
||||
onValueChange = { lon.value = it },
|
||||
label = { Text(text = "Longitude") },
|
||||
label = { Text(text = translation["longitude_dialog_hint"]) },
|
||||
leadingIcon = { Icon(Icons.Filled.Navigation, contentDescription = null) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
|
||||
Reference in New Issue
Block a user