Merge gitea/dev into dev (resolve conflict: keep GitHub debug/story features)

This commit is contained in:
root
2026-05-30 13:37:30 +02:00
73 changed files with 9321 additions and 4119 deletions

View File

@@ -113,7 +113,6 @@ android {
compose = true
buildConfig = true
}
signingConfigs {
create("release") {
storeFile = File(System.getProperty("user.home"), ".android/purrfectsnap-release.keystore")
@@ -166,6 +165,9 @@ android {
val releaseKeyAlias = gradleOrEnv("PS_RELEASE_KEY_ALIAS", providers)
if (releaseStore.exists() && !releaseStorePass.isNullOrBlank() && !releaseKeyAlias.isNullOrBlank()) {
signingConfig = signingConfigs.getByName("release")
} else {
// Keep local release builds installable when private release credentials are unavailable.
signingConfig = signingConfigs.getByName("debug")
}
}
debug {
@@ -356,6 +358,7 @@ afterEvaluate {
}
}
}
}
properties["debug_flavor"]?.let {

View File

@@ -5,6 +5,9 @@ import android.content.Intent
import android.os.IBinder
import android.os.ParcelFileDescriptor
import android.os.RemoteException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import me.eternal.purrfectsnap.RemoteSideContext
import me.eternal.purrfectsnap.SharedContextHolder
@@ -219,19 +222,42 @@ class BridgeService : Service() {
triggerScopeSync(SocialScope.getByName(scope), id, true)
}
private val friendAccumulator = mutableListOf<MessagingFriendInfo>()
private val groupAccumulator = mutableListOf<MessagingGroupInfo>()
override fun passGroupsAndFriends(
groups: List<String>,
friends: List<String>
friends: List<String>,
chunkIndex: Int,
totalChunks: Int
) {
remoteSideContext.log.verbose("Received ${groups.size} groups and ${friends.size} friends")
val parsedFriends = friends.mapNotNull { toParcelable<MessagingFriendInfo>(it) }
val parsedGroups = groups.mapNotNull { toParcelable<MessagingGroupInfo>(it) }
pendingSocialSnapshotCallback?.let { callback ->
pendingSocialSnapshotCallback = null
callback(parsedFriends, parsedGroups)
synchronized(friendAccumulator) {
if (chunkIndex == 0) {
friendAccumulator.clear()
groupAccumulator.clear()
}
remoteSideContext.log.verbose("Received chunk $chunkIndex/$totalChunks: ${groups.size} groups, ${friends.size} friends")
friendAccumulator.addAll(friends.mapNotNull { toParcelable<MessagingFriendInfo>(it) })
groupAccumulator.addAll(groups.mapNotNull { toParcelable<MessagingGroupInfo>(it) })
if (chunkIndex == totalChunks - 1) {
val finalFriends = friendAccumulator.toList()
val finalGroups = groupAccumulator.toList()
friendAccumulator.clear()
groupAccumulator.clear()
remoteSideContext.coroutineScope.launch(Dispatchers.IO) {
pendingSocialSnapshotCallback?.let { callback ->
pendingSocialSnapshotCallback = null
callback(finalFriends, finalGroups)
}
remoteSideContext.database.replaceMessagingData(finalFriends, finalGroups)
remoteSideContext.database.messagingDataFlow.tryEmit(finalFriends to finalGroups)
}
}
}
remoteSideContext.database.replaceMessagingData(parsedFriends, parsedGroups)
remoteSideContext.database.receiveMessagingDataCallback(parsedFriends, parsedGroups)
}
override fun getScopeNotes(id: String): String? {

View File

@@ -283,7 +283,7 @@ class DownloadProcessor (
while (true) {
val existingFile = outputFileFolder.findFile(finalFileName) ?: break
if (existingFile.length() == inputFile.length()) {
if (existingFile.length() == inputFile.length() && !remoteSideContext.config.root.downloader.allowDuplicate.get()) {
val existingInputStream = remoteSideContext.androidContext.contentResolver.openInputStream(existingFile.uri)
if (existingInputStream != null && streamsMatch(existingInputStream, inputFile.inputStream())) {
return GallerySaveResult(existingFile.uri, alreadyDownloaded = true)
@@ -376,7 +376,7 @@ class DownloadProcessor (
var destFile = File(destDir, fileName)
var suffix = 1
while (destFile.exists()) {
if (destFile.length() == inputFile.length() && streamsMatch(destFile.inputStream(), inputFile.inputStream())) {
if (destFile.length() == inputFile.length() && !remoteSideContext.config.root.downloader.allowDuplicate.get() && streamsMatch(destFile.inputStream(), inputFile.inputStream())) {
return GallerySaveResult(Uri.fromFile(destFile), alreadyDownloaded = true)
}
destFile = File(destDir, appendNameSuffix(fileName, suffix++))

View File

@@ -151,31 +151,37 @@ class FFMpegProcessor(
}
val outputArguments = ArgumentList().apply {
this += "-preset" to (ffmpegOptions.preset.getNullable() ?: "ultrafast")
this += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() }?.lowercase() ?: "libx264")
this += "-c:a" to (ffmpegOptions.customAudioCodec.get().takeIf { it.isNotEmpty() }?.lowercase() ?: "copy")
this += "-crf" to ffmpegOptions.constantRateFactor.get().let { "\"$it\"" }
this += "-b:v" to ffmpegOptions.videoBitrate.get().toString() + "K"
this += "-b:a" to ffmpegOptions.audioBitrate.get().toString() + "K"
}
fun applyVideoArguments() {
outputArguments += "-preset" to (ffmpegOptions.preset.getNullable() ?: "ultrafast")
outputArguments += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() }?.lowercase() ?: "libx264")
outputArguments += "-crf" to ffmpegOptions.constantRateFactor.get().let { "\"$it\"" }
outputArguments += "-b:v" to ffmpegOptions.videoBitrate.get().toString() + "K"
}
when (args.action) {
Action.DOWNLOAD_DASH -> {
applyVideoArguments()
outputArguments += "-ss" to "'${args.startTime}ms'"
if (args.duration != null) {
outputArguments += "-t" to "'${args.duration}ms'"
}
}
Action.MERGE_OVERLAY -> {
applyVideoArguments()
inputArguments += "-i" to args.overlay!!.absolutePath
outputArguments += "-filter_complex" to "\"[1:v][0:v]scale2ref=w=iw:h=ih[ovrl][main];[main][ovrl]overlay=(W-w)/2:(H-h)/2,scale=2*trunc(iw/2):2*trunc(ih/2)\""
outputArguments += "-filter_complex" to "\"[0]scale2ref[img][vid];[img]setsar=1[img];[vid]nullsink;[img][1]overlay=(W-w)/2:(H-h)/2,scale=2*trunc(iw*sar/2):2*trunc(ih/2)\""
}
Action.CONVERSION -> {
if (ffmpegOptions.customAudioCodec.isEmpty()) {
outputArguments -= "-c:a"
}
outputArguments -= "-c:v"
args.videoCodec?.let {
applyVideoArguments()
outputArguments -= "-c:v"
outputArguments += "-c:v" to it
} ?: run {
outputArguments += "-vn"
@@ -186,6 +192,7 @@ class FFMpegProcessor(
}
}
Action.MERGE_MEDIA -> {
applyVideoArguments()
inputArguments.clear()
val filesInfo = args.inputs.mapNotNull { file ->
runCatching {
@@ -211,7 +218,7 @@ class FFMpegProcessor(
filterSecondPart.append("[v$index][$index:a]")
} else {
containsNoSound = true
filterSecondPart.append("[v$index][${filesInfo.size}]")
filterSecondPart.append("[v$index][${filesInfo.size}:a]")
}
inputArguments += "-i" to file
}
@@ -228,9 +235,9 @@ class FFMpegProcessor(
outputArguments += "-fps_mode" to "vfr"
outputArguments += "-filter_complex" to "\"$filterFirstPart ${filterSecondPart}concat=n=${filesInfo.size}:v=1:a=1[vout][aout]\""
outputArguments += "-map" to "\"[aout]\""
outputArguments += "-map" to "\"[vout]\""
outputArguments += "-filter_complex" to "$filterFirstPart ${filterSecondPart}concat=n=${filesInfo.size}:v=1:a=1[vout][aout]"
outputArguments += "-map" to "[aout]"
outputArguments += "-map" to "[vout]"
} finally {
filesInfo.forEach { it.second.close() }
}
@@ -264,8 +271,8 @@ class FFMpegProcessor(
filterParts.append("[a$index]")
}
filterParts.append("amix=inputs=${args.inputs.size}:duration=longest:normalize=0[aout]")
outputArguments += "-filter_complex" to "\"$filterParts\""
outputArguments += "-map" to "\"[aout]\""
outputArguments += "-filter_complex" to filterParts.toString()
outputArguments += "-map" to "[aout]"
}
}
outputArguments += args.output.absolutePath

View File

@@ -5,6 +5,8 @@ import me.eternal.purrfectsnap.RemoteSideContext
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
import me.eternal.purrfectsnap.common.util.SQLiteDatabaseHelper
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
@@ -15,7 +17,11 @@ class AppDatabase(
val executor: ExecutorService = Executors.newSingleThreadExecutor()
lateinit var database: SQLiteDatabase
var receiveMessagingDataCallback: (friends: List<MessagingFriendInfo>, groups: List<MessagingGroupInfo>) -> Unit = { _, _ -> }
// Multi-subscriber event stream for messaging data updates
val messagingDataFlow = MutableSharedFlow<Pair<List<MessagingFriendInfo>, List<MessagingGroupInfo>>>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
fun executeAsync(block: () -> Unit) {
executor.execute {
@@ -113,6 +119,22 @@ class AppDatabase(
"id CHAR(36) PRIMARY KEY",
"content TEXT",
),
"assistant_registry" to listOf(
"id VARCHAR PRIMARY KEY",
"kind VARCHAR",
"title VARCHAR",
"category VARCHAR",
"path TEXT",
"description TEXT",
"settingKey VARCHAR",
"screenRoute VARCHAR",
"allowedActions TEXT",
"allowedValues TEXT",
"aliases TEXT",
"commonTypos TEXT",
"examples TEXT",
"searchTokens TEXT",
),
))
}
}

View File

@@ -0,0 +1,93 @@
package me.eternal.purrfectsnap.storage
import android.content.ContentValues
import me.eternal.purrfectsnap.common.util.ktx.getStringOrNull
import org.json.JSONArray
data class AssistantRegistryEntry(
val id: String,
val kind: String,
val title: String,
val category: String,
val path: String,
val description: String,
val settingKey: String? = null,
val screenRoute: String? = null,
val allowedActions: List<String> = emptyList(),
val allowedValues: List<String> = emptyList(),
val aliases: List<String> = emptyList(),
val commonTypos: List<String> = emptyList(),
val examples: List<String> = emptyList(),
val searchTokens: List<String> = emptyList()
)
private fun List<String>.toJsonArrayString(): String = JSONArray(this).toString()
private fun parseStringList(raw: String?): List<String> {
if (raw.isNullOrBlank()) return emptyList()
return runCatching {
val array = JSONArray(raw)
buildList {
for (index in 0 until array.length()) {
array.optString(index).takeIf { it.isNotBlank() }?.let(::add)
}
}
}.getOrDefault(emptyList())
}
fun AppDatabase.replaceAssistantRegistry(entries: List<AssistantRegistryEntry>) {
database.beginTransaction()
try {
database.execSQL("DELETE FROM assistant_registry")
entries.forEach { entry ->
database.insert(
"assistant_registry",
null,
ContentValues().apply {
put("id", entry.id)
put("kind", entry.kind)
put("title", entry.title)
put("category", entry.category)
put("path", entry.path)
put("description", entry.description)
put("settingKey", entry.settingKey)
put("screenRoute", entry.screenRoute)
put("allowedActions", entry.allowedActions.toJsonArrayString())
put("allowedValues", entry.allowedValues.toJsonArrayString())
put("aliases", entry.aliases.toJsonArrayString())
put("commonTypos", entry.commonTypos.toJsonArrayString())
put("examples", entry.examples.toJsonArrayString())
put("searchTokens", entry.searchTokens.toJsonArrayString())
}
)
}
database.setTransactionSuccessful()
} finally {
database.endTransaction()
}
}
fun AppDatabase.getAssistantRegistryEntries(): List<AssistantRegistryEntry> {
return database.rawQuery("SELECT * FROM assistant_registry", null).use { cursor ->
val entries = mutableListOf<AssistantRegistryEntry>()
while (cursor.moveToNext()) {
entries += AssistantRegistryEntry(
id = cursor.getStringOrNull("id") ?: continue,
kind = cursor.getStringOrNull("kind") ?: "feature",
title = cursor.getStringOrNull("title") ?: "",
category = cursor.getStringOrNull("category") ?: "",
path = cursor.getStringOrNull("path") ?: "",
description = cursor.getStringOrNull("description") ?: "",
settingKey = cursor.getStringOrNull("settingKey"),
screenRoute = cursor.getStringOrNull("screenRoute"),
allowedActions = parseStringList(cursor.getStringOrNull("allowedActions")),
allowedValues = parseStringList(cursor.getStringOrNull("allowedValues")),
aliases = parseStringList(cursor.getStringOrNull("aliases")),
commonTypos = parseStringList(cursor.getStringOrNull("commonTypos")),
examples = parseStringList(cursor.getStringOrNull("examples")),
searchTokens = parseStringList(cursor.getStringOrNull("searchTokens"))
)
}
entries
}
}

View File

@@ -97,15 +97,16 @@ fun AppDatabase.replaceMessagingData(
database.beginTransaction()
try {
friends.forEach { friend ->
// Industrial Filter: Only update existing friends, never auto-insert new ones.
database.execSQL(
"INSERT OR REPLACE INTO friends (userId, dmConversationId, displayName, mutableUsername, bitmojiId, selfieId) VALUES (?, ?, ?, ?, ?, ?)",
"UPDATE friends SET dmConversationId = ?, displayName = ?, mutableUsername = ?, bitmojiId = ?, selfieId = ? WHERE userId = ?",
arrayOf<Any?>(
friend.userId,
friend.dmConversationId,
friend.displayName,
friend.mutableUsername,
friend.bitmojiId,
friend.selfieId
friend.selfieId,
friend.userId
)
)
@@ -124,12 +125,13 @@ fun AppDatabase.replaceMessagingData(
}
groups.forEach { group ->
// Industrial Filter: Only update existing groups.
database.execSQL(
"INSERT OR REPLACE INTO groups (conversationId, name, participantsCount) VALUES (?, ?, ?)",
"UPDATE groups SET name = ?, participantsCount = ? WHERE conversationId = ?",
arrayOf<Any?>(
group.conversationId,
group.name,
group.participantsCount
group.participantsCount,
group.conversationId
)
)
}
@@ -139,10 +141,8 @@ fun AppDatabase.replaceMessagingData(
database.endTransaction()
}
// Notify with the full updated list from the DB
val allFriends = getFriends(descOrder = true)
val allGroups = getGroups()
receiveMessagingDataCallback(allFriends, allGroups)
// Notify all observers with the raw sync data (AddFriendDialog needs this)
messagingDataFlow.tryEmit(friends to groups)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -133,16 +133,16 @@ class MainActivity : ComponentActivity() {
if (shouldShowAbiWarning) {
AestheticDialog(
onDismissRequest = {},
title = managerContext.translation["wrong_apk_title"],
title = managerContext.translation["setup.activity.wrong_apk_title"],
text = "",
icon = Icons.Filled.Warning,
confirmButtonText = managerContext.translation["common.close"],
confirmButtonText = managerContext.translation["setup.activity.close_button"],
onConfirm = { (context as? Activity)?.finishAffinity() },
showCloseButton = false,
opaque = true,
customContent = {
Text(
text = managerContext.translation["wrong_apk_message"],
text = managerContext.translation["setup.activity.wrong_apk_message"],
color = PurrfectPalette.textSecondary,
lineHeight = 18.sp
)

View File

@@ -269,7 +269,7 @@ fun FloatingTopBar(
if (onBack != null) {
translationX = morphingParams.horizontalShift.toPx()
}
},
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {

View File

@@ -113,8 +113,8 @@ object Updater {
fun getLatestRelease(channel: Channel): LatestRelease? {
return cache.getOrPut(channel) {
if (BuildConfig.DEBUG) {
fetchLatestDebugCI() ?: fetchLatestRelease(channel)
if (BuildConfig.DEBUG && channel == Channel.STABLE) {
fetchLatestDebugCI() ?: fetchLatestRelease(Channel.STABLE)
} else {
fetchLatestRelease(channel)
}

View File

@@ -181,7 +181,6 @@ class FeaturesRootSection : Routes.Route() {
}
internal fun getRandomizedProfileSnapshot(): String {
context.config.load()
return context.config.root.experimental.spoof.randomizeDeviceProfile.currentProfileSnapshot.getNullable()
?.takeIf { it.isNotBlank() }
?: (context.translation["manager.dialogs.randomize_device_profile.empty"]
@@ -231,9 +230,12 @@ class FeaturesRootSection : Routes.Route() {
?: error("Failed to read randomized profile backup")
val profile = RandomizedDeviceProfile.fromJson(importedJson)
val generationToken = UUID.randomUUID().toString()
val profileJson = profile.toJson().toString()
// Save to local prefs for legacy compatibility
context.androidContext.getSharedPreferences("purrfectsnap_spoof", 0)
.edit()
.putString("randomized_device_profile", profile.toJson().toString())
.putString("randomized_device_profile", profileJson)
.putString("randomized_device_profile_token", generationToken)
.putString("android_id", profile.androidId)
.putString("advertising_id", profile.advertisingId)
@@ -246,6 +248,8 @@ class FeaturesRootSection : Routes.Route() {
val randomizeConfig = context.config.root.experimental.spoof.randomizeDeviceProfile
randomizeConfig.profileGenerationToken.set(generationToken)
randomizeConfig.currentProfileSnapshot.set(profile.toJson().toString(2))
randomizeConfig.profileData.set(profileJson) // Shared storage fix
context.config.writeConfig()
onConfigChanged()
context.shortToast("Randomized profile restored. Restart Snapchat to apply it.")

View File

@@ -4,18 +4,14 @@ import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material.icons.filled.GroupAdd
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.RadioButton
import androidx.compose.material3.RadioButtonDefaults
import androidx.compose.material3.Surface
@@ -29,6 +25,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -38,10 +35,10 @@ 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
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList
import me.eternal.purrfectsnap.storage.clearRuleIds
import me.eternal.purrfectsnap.storage.getRuleIds
import me.eternal.purrfectsnap.storage.setRule
@@ -140,9 +137,12 @@ class ManageRuleFeature : Routes.Route() {
}
val updateDispatcher = rememberAsyncUpdateDispatcher()
val currentRuleIds by rememberAsyncMutableState(defaultValue = mutableListOf(), updateDispatcher = updateDispatcher) {
val currentRuleIds = rememberAsyncMutableStateList(defaultValue = emptyList()) {
context.database.getRuleIds(currentRuleType.key)
}
val currentRuleIdSet = remember(currentRuleIds.size) {
currentRuleIds.toSet()
}
fun setRuleState(newState: RuleState?) {
ruleState = newState
@@ -163,12 +163,12 @@ class ManageRuleFeature : Routes.Route() {
fun showAddFriendDialog() {
addFriendDialog = AddFriendDialog(
context = context,
pinnedIds = currentRuleIds,
pinnedIds = currentRuleIds.toList(),
actionHandler = Actions(
onFriendState = { friend, state ->
context.database.setRule(friend.userId, currentRuleType.key, state)
if (state) {
currentRuleIds.add(friend.userId)
if (!currentRuleIdSet.contains(friend.userId)) currentRuleIds.add(friend.userId)
} else {
currentRuleIds.remove(friend.userId)
}
@@ -176,16 +176,16 @@ class ManageRuleFeature : Routes.Route() {
onGroupState = { group, state ->
context.database.setRule(group.conversationId, currentRuleType.key, state)
if (state) {
currentRuleIds.add(group.conversationId)
if (!currentRuleIdSet.contains(group.conversationId)) currentRuleIds.add(group.conversationId)
} else {
currentRuleIds.remove(group.conversationId)
}
},
getFriendState = { friend ->
currentRuleIds.contains(friend.userId)
currentRuleIdSet.contains(friend.userId)
},
getGroupState = { group ->
currentRuleIds.contains(group.conversationId)
currentRuleIdSet.contains(group.conversationId)
}
)
)
@@ -230,59 +230,62 @@ class ManageRuleFeature : Routes.Route() {
title = remember { context.translation[propertyKeyPair.key.propertyName()] },
onBack = { routes.navController.popBackStack() },
modifier = Modifier
.zIndex(2f)
.zIndex(10f)
.onGloballyPositioned {
val newHeight = with(density) { it.size.height.toDp() }
if (newHeight != topBarHeight) topBarHeight = newHeight
}
)
Column(
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(top = topBarHeight + 10.dp)
.padding(horizontal = 12.dp, vertical = 10.dp)
.verticalScroll(rememberRouteScrollState(routeInfo.id)),
.padding(top = topBarHeight + 10.dp),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
val headerShape = RoundedCornerShape(22.dp)
Surface(
shape = headerShape,
color = Color.White.copy(alpha = 0.04f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
item {
val headerShape = RoundedCornerShape(22.dp)
Surface(
shape = headerShape,
color = Color.White.copy(alpha = 0.04f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
)
)
) {
Column(
modifier = Modifier
.background(PurrfectPalette.cardOverlay, headerShape)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = context.translation[propertyKeyPair.key.propertyDescription()],
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
color = PurrfectPalette.textSecondary
)
Column(
modifier = Modifier
.background(PurrfectPalette.cardOverlay, headerShape)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = context.translation[propertyKeyPair.key.propertyDescription()] ?: "",
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
color = PurrfectPalette.textSecondary
)
}
}
}
SelectRuleTypeRadio(
checked = ruleState == null,
text = translation["disable_state_option"],
onStateChanged = { setRuleState(null) }
) {
Text(text = translation["disable_state_subtext"], fontWeight = FontWeight.Normal, fontSize = 12.sp, color = PurrfectPalette.textSecondary)
item {
SelectRuleTypeRadio(
checked = ruleState == null,
text = translation["disable_state_option"] ?: "Disabled",
onStateChanged = { setRuleState(null) }
) {
Text(text = translation["disable_state_subtext"] ?: "", fontWeight = FontWeight.Normal, fontSize = 12.sp, color = PurrfectPalette.textSecondary)
}
}
val manageLabel = when (ruleState) {
@@ -291,112 +294,120 @@ class ManageRuleFeature : Routes.Route() {
else -> null
}
SelectRuleTypeRadio(
checked = ruleState == RuleState.WHITELIST,
text = translation["whitelist_state_option"],
onStateChanged = { setRuleState(RuleState.WHITELIST) }
) {
Text(
text = translation.format("whitelist_state_subtext", "count" to currentRuleIds.size.toString()),
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
color = PurrfectPalette.textSecondary
)
Button(
onClick = { showAddFriendDialog() },
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
contentColor = Color.White
item {
SelectRuleTypeRadio(
checked = ruleState == RuleState.WHITELIST,
text = translation["whitelist_state_option"] ?: "Whitelist",
onStateChanged = { setRuleState(RuleState.WHITELIST) }
) {
Text(
text = translation.format("whitelist_state_subtext", "count" to currentRuleIds.size.toString()),
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
color = PurrfectPalette.textSecondary
)
) {
Text(text = translation["whitelist_state_button"])
}
}
SelectRuleTypeRadio(
checked = ruleState == RuleState.BLACKLIST,
text = translation["blacklist_state_option"],
onStateChanged = { setRuleState(RuleState.BLACKLIST) }
) {
Text(
text = translation.format("blacklist_state_subtext", "count" to currentRuleIds.size.toString()),
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
color = PurrfectPalette.textSecondary
)
Button(
onClick = { showAddFriendDialog() },
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
contentColor = Color.White
)
) {
Text(text = translation["blacklist_state_button"])
}
}
Surface(
shape = RoundedCornerShape(22.dp),
color = Color.White.copy(alpha = 0.04f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(22.dp))
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Surface(
shape = CircleShape,
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
modifier = Modifier.size(46.dp)
) {
Box(
modifier = Modifier
.fillMaxSize()
.clip(CircleShape)
.background(PurrfectPalette.glowSecondary.copy(alpha = 0.22f)),
contentAlignment = Alignment.Center
) {
Icon(Icons.Default.DeleteSweep, contentDescription = null, tint = Color.White)
}
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = translation["clear_list_button"],
color = Color.White,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (!manageLabel.isNullOrBlank()) {
Text(
text = manageLabel,
color = PurrfectPalette.textSecondary,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Button(
onClick = { confirmationDialog = true },
onClick = { showAddFriendDialog() },
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.08f),
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
contentColor = Color.White
)
) {
Text(text = translation["dialog_clear_confirm_button"])
Text(text = translation["whitelist_state_button"] ?: "Manage")
}
}
}
Spacer(modifier = Modifier.height(routes.bottomPadding))
item {
SelectRuleTypeRadio(
checked = ruleState == RuleState.BLACKLIST,
text = translation["blacklist_state_option"] ?: "Blacklist",
onStateChanged = { setRuleState(RuleState.BLACKLIST) }
) {
Text(
text = translation.format("blacklist_state_subtext", "count" to currentRuleIds.size.toString()),
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
color = PurrfectPalette.textSecondary
)
Button(
onClick = { showAddFriendDialog() },
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
contentColor = Color.White
)
) {
Text(text = translation["blacklist_state_button"] ?: "Manage")
}
}
}
item {
Surface(
shape = RoundedCornerShape(22.dp),
color = Color.White.copy(alpha = 0.04f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(22.dp))
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Surface(
shape = CircleShape,
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
modifier = Modifier.size(46.dp)
) {
Box(
modifier = Modifier
.fillMaxSize()
.clip(CircleShape)
.background(PurrfectPalette.glowSecondary.copy(alpha = 0.22f)),
contentAlignment = Alignment.Center
) {
Icon(Icons.Default.DeleteSweep, contentDescription = null, tint = Color.White)
}
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = translation["clear_list_button"] ?: "Clear List",
color = Color.White,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (!manageLabel.isNullOrBlank()) {
Text(
text = manageLabel,
color = PurrfectPalette.textSecondary,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Button(
onClick = { confirmationDialog = true },
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.08f),
contentColor = Color.White
)
) {
Text(text = translation["dialog_clear_confirm_button"] ?: "Clear")
}
}
}
}
item {
Spacer(modifier = Modifier.height(routes.bottomPadding))
}
}
}
}

View File

@@ -32,7 +32,6 @@ import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.PurrfectMarqueeText
import me.eternal.purrfectsnap.ui.util.scaleOnPress
class HomeAbout : Routes.Route() {
@@ -65,6 +64,7 @@ class HomeAbout : Routes.Route() {
name: String,
imageRes: Int,
avenirNext: FontFamily,
subtitle: String? = null,
modifier: Modifier = Modifier
) {
val tapSource = remember { MutableInteractionSource() }
@@ -85,7 +85,9 @@ class HomeAbout : Routes.Route() {
routes.retroGame.navigate()
}
},
modifier = modifier.scaleOnPress(tapSource),
modifier = modifier
.height(150.dp)
.scaleOnPress(tapSource),
interactionSource = tapSource,
shape = RoundedCornerShape(22.dp),
color = Color.White.copy(alpha = 0.06f),
@@ -94,9 +96,11 @@ class HomeAbout : Routes.Route() {
shadowElevation = 0.dp
) {
Column(
modifier = Modifier.padding(14.dp),
modifier = Modifier
.fillMaxSize()
.padding(14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
verticalArrangement = Arrangement.Center
) {
Surface(
modifier = Modifier.size(64.dp),
@@ -111,15 +115,28 @@ class HomeAbout : Routes.Route() {
modifier = Modifier.fillMaxSize().clip(CircleShape)
)
}
PurrfectMarqueeText(
Text(
text = name,
color = Color.White,
style = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
fontFamily = avenirNext
)
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
fontFamily = avenirNext,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
)
subtitle?.takeIf { it.isNotBlank() }?.let {
Spacer(modifier = Modifier.height(6.dp))
Text(
text = it,
color = PurrfectPalette.textSecondary,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}

View File

@@ -127,6 +127,8 @@ import me.eternal.purrfectsnap.storage.getQuickTiles
import me.eternal.purrfectsnap.storage.setQuickTiles
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantEntry
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantTriggerStyle
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.manager.data.UpdateDownloader
import me.eternal.purrfectsnap.ui.manager.data.Updater
@@ -271,27 +273,31 @@ class HomeRootSection : Routes.Route() {
icon: ImageVector,
label: String? = null,
contentDescription: String? = label,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
Surface(
modifier = modifier.height(36.dp),
shape = RoundedCornerShape(40),
color = Color.White.copy(alpha = 0.06f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(40))
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 8.dp),
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
horizontalArrangement = Arrangement.Center
) {
Icon(icon, contentDescription = contentDescription, tint = Color.White)
Icon(icon, contentDescription = contentDescription, tint = Color.White, modifier = Modifier.size(20.dp))
label?.let {
Spacer(modifier = Modifier.width(6.dp))
Text(
text = it,
color = Color.White,
fontSize = 13.sp,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
@@ -323,13 +329,20 @@ class HomeRootSection : Routes.Route() {
@Composable
private fun RowScope.HomeActionChips() {
ManagerAssistantEntry(
context = context,
routes = routes,
style = ManagerAssistantTriggerStyle.DEFAULT
)
TopBarActionChip(
icon = Icons.Filled.BugReport,
label = context.translation["manager.routes.home_logs"]
label = context.translation["manager.routes.home_logs"],
modifier = Modifier
) { routes.homeLogs.navigate() }
TopBarActionChip(
icon = Icons.Filled.Info,
label = translation["manager.routes.home_about"]
label = translation["manager.routes.home_about"],
modifier = Modifier
) { routes.about.navigate() }
}
@@ -733,9 +746,8 @@ class HomeRootSection : Routes.Route() {
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.wrapContentWidth(),
) {
Spacer(modifier = Modifier.weight(1f))
HomeActionChips()
}
}

View File

@@ -75,34 +75,9 @@ class HomeSettings : Routes.Route() {
internal fun scheduleUpdateCheck() {
val workManager = WorkManager.getInstance(context.androidContext)
val updateSettings = context.config.root.global.updateSettings
var configDirty = false
val autoUpdateCheck = updateSettings.autoUpdateCheck.getNullable() ?: run {
configDirty = true
updateSettings.autoUpdateCheck.set(true)
true
}
val frequency = updateSettings.updateCheckFrequency.getNullable() ?: run {
configDirty = true
updateSettings.updateCheckFrequency.set("daily")
"daily"
}
val updateChannel = updateSettings.updateChannel.getNullable() ?: run {
configDirty = true
updateSettings.updateChannel.set("stable")
"stable"
}
if (configDirty) {
context.config.writeConfig()
}
val autoUpdateCheck = updateSettings.autoUpdateCheck.get()
if (autoUpdateCheck) {
val repeatInterval = when (frequency) {
"daily" -> 1L
"weekly" -> 7L
"monthly" -> 30L
else -> 1L
}
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
@@ -112,10 +87,10 @@ class HomeSettings : Routes.Route() {
.putString("channel_description", translation["update_notification_channel_description"])
.putString("notification_title", translation["update_notification_title"])
.putString("notification_text", translation["update_notification_text"])
.putString("update_channel", updateChannel)
.putString("update_channel", "stable")
.build()
val workRequest = PeriodicWorkRequestBuilder<UpdateCheckWorker>(repeatInterval, TimeUnit.DAYS)
val workRequest = PeriodicWorkRequestBuilder<UpdateCheckWorker>(1, TimeUnit.DAYS)
.setConstraints(constraints)
.setInputData(inputData)
.build()

View File

@@ -279,8 +279,9 @@ class ManageScriptReposSection : Routes.Route() {
}
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
val repositories by remember(refreshTrigger.value) {
mutableStateOf<List<String>>(runBlocking { context.database.getRepositories("script") })
var repositories by remember { mutableStateOf<List<String>>(emptyList()) }
LaunchedEffect(refreshTrigger.value) {
repositories = context.database.getRepositories("script")
}
val density = LocalDensity.current
val statusBarTopPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()

View File

@@ -26,9 +26,11 @@ import kotlinx.coroutines.*
import me.eternal.purrfectsnap.RemoteSideContext
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
import me.eternal.purrfectsnap.common.data.MessagingRuleType
import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie
import me.eternal.purrfectsnap.storage.getFriends
import me.eternal.purrfectsnap.storage.getGroups
import me.eternal.purrfectsnap.storage.getRuleIds
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.coil.BitmojiImage
@@ -223,32 +225,34 @@ class AddFriendDialog(
friends: List<MessagingFriendInfo>,
groups: List<MessagingGroupInfo>
) {
cachedFriends = friends.run {
if (pinnedIds != null) {
sortedBy { -pinnedIds.indexOf(it.userId) }
} else {
this
coroutineScope.launch(Dispatchers.IO) {
val sortedFriends = context.sortSocialFriends(friends, pinnedIds = pinnedIds)
val sortedGroups = groups.run {
if (pinnedIds != null) {
sortedBy { -pinnedIds.indexOf(it.conversationId) }
} else {
// Priority sort for whitelisted groups
val whitelistedIds = context.database.getRuleIds(MessagingRuleType.STEALTH.key).toSet()
sortedWith { a, b ->
val aSelected = whitelistedIds.contains(a.conversationId)
val bSelected = whitelistedIds.contains(b.conversationId)
if (aSelected != bSelected) if (aSelected) -1 else 1
else a.name.compareTo(b.name, ignoreCase = true)
}
}
}
}
cachedGroups = groups.run {
if (pinnedIds != null) {
sortedBy { -pinnedIds.indexOf(it.conversationId) }
} else {
this
withContext(Dispatchers.Main) {
cachedFriends = sortedFriends
cachedGroups = sortedGroups
if (friends.isNotEmpty() || groups.isNotEmpty()) {
timeoutJob?.cancel()
hasFetchError = false
}
}
}
if (friends.isNotEmpty() || groups.isNotEmpty()) {
timeoutJob?.cancel()
hasFetchError = false
}
}
val updateSnapshot: (List<MessagingFriendInfo>, List<MessagingGroupInfo>) -> Unit = { friends, groups ->
coroutineScope.launch {
applySnapshot(friends, groups)
}
}
// Initial database load
withContext(Dispatchers.IO) {
applySnapshot(
context.database.getFriends(descOrder = true),
@@ -256,20 +260,11 @@ class AddFriendDialog(
)
}
context.database.receiveMessagingDataCallback = updateSnapshot
// Real-time synchronization flow
context.requestSocialSnapshotRefresh()
coroutineScope.launch(Dispatchers.IO) {
repeat(25) {
delay(1000)
val dbFriends = context.database.getFriends(descOrder = true)
val dbGroups = context.database.getGroups()
if (dbFriends.isNotEmpty() || dbGroups.isNotEmpty()) {
withContext(Dispatchers.Main) {
applySnapshot(dbFriends, dbGroups)
}
return@launch
}
coroutineScope.launch {
context.database.messagingDataFlow.collect { (friends, groups) ->
applySnapshot(friends, groups)
}
}
@@ -286,7 +281,6 @@ class AddFriendDialog(
onDispose {
timeoutJob?.cancel()
context.bridgeService?.clearEphemeralSocialSnapshotRequest()
context.database.receiveMessagingDataCallback = { _, _ -> }
}
}
@@ -346,6 +340,7 @@ class AddFriendDialog(
it.mutableUsername.contains(searchKeyword.value, ignoreCase = true) ||
it.displayName?.contains(searchKeyword.value, ignoreCase = true) == true
} ?: cachedFriends!!
val selectedFriendCount by remember(filteredFriends) {
derivedStateOf {
filteredFriends.count { friend ->
@@ -356,6 +351,16 @@ class AddFriendDialog(
val hasFriendsSelected = selectedFriendCount > 0
val allFriendsSelected = filteredFriends.isNotEmpty() && selectedFriendCount == filteredFriends.size
val selectedGroupCount by remember(filteredGroups) {
derivedStateOf {
filteredGroups.count { group ->
stateCache[group.conversationId] ?: actionHandler.getGroupState(group)
}
}
}
val hasGroupsSelected = selectedGroupCount > 0
val allGroupsSelected = filteredGroups.isNotEmpty() && selectedGroupCount == filteredGroups.size
DialogHeader(searchKeyword)
LazyColumn(
@@ -365,14 +370,54 @@ class AddFriendDialog(
) {
item {
if (filteredGroups.isNotEmpty()) {
Text(
text = translation["category_groups"],
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp, top = 8.dp),
color = Color.White
)
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = translation["category_groups"],
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(
onClick = {
coroutineScope.launch(Dispatchers.IO) {
filteredGroups.forEach { group ->
stateCache[group.conversationId] = true
actionHandler.onGroupState(group, true)
}
}
},
enabled = !allGroupsSelected
) {
Text(
text = context.translation["manager.dialogs.messaging_action.select_all_button"],
color = if (allGroupsSelected) Color.White.copy(alpha = 0.45f) else PurrfectPalette.glowSecondary
)
}
TextButton(
onClick = {
coroutineScope.launch(Dispatchers.IO) {
filteredGroups.forEach { group ->
stateCache[group.conversationId] = false
actionHandler.onGroupState(group, false)
}
}
},
enabled = hasGroupsSelected
) {
Text(
text = translation["unselect_all_button"],
color = if (hasGroupsSelected) PurrfectPalette.glowPrimary else Color.White.copy(alpha = 0.45f)
)
}
}
}
}
}
@@ -417,11 +462,7 @@ class AddFriendDialog(
) {
Text(
text = context.translation["manager.dialogs.messaging_action.select_all_button"],
color = if (allFriendsSelected) {
Color.White.copy(alpha = 0.45f)
} else {
PurrfectPalette.glowSecondary
}
color = if (allFriendsSelected) Color.White.copy(alpha = 0.45f) else PurrfectPalette.glowSecondary
)
}
TextButton(
@@ -437,11 +478,7 @@ class AddFriendDialog(
) {
Text(
text = translation["unselect_all_button"],
color = if (hasFriendsSelected) {
PurrfectPalette.glowPrimary
} else {
Color.White.copy(alpha = 0.45f)
}
color = if (hasFriendsSelected) PurrfectPalette.glowPrimary else Color.White.copy(alpha = 0.45f)
)
}
}

View File

@@ -35,6 +35,7 @@ import me.eternal.purrfectsnap.storage.getFriendInfo
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.util.Dialog
import me.eternal.purrfectsnap.ui.util.coil.ImageRequestHelper
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import java.io.File
import java.text.DateFormat
import java.util.Date
@@ -44,12 +45,11 @@ import kotlin.math.absoluteValue
class LoggedStories : Routes.Route() {
override val title: @Composable () -> Unit = {
val navBackStackEntry by routes.navController.currentBackStackEntryAsState()
val text = remember(navBackStackEntry) {
navBackStackEntry?.arguments?.getString("id")?.let {
context.database.getFriendInfo(it)?.displayName
}
val userId = navBackStackEntry?.arguments?.getString("id")
val displayName by rememberAsyncMutableState(defaultValue = null) {
userId?.let { context.database.getFriendInfo(it)?.displayName }
}
text?.let { Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis) }
displayName?.let { Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis) }
}
@OptIn(ExperimentalCoilApi::class, ExperimentalLayoutApi::class)
@@ -57,7 +57,9 @@ class LoggedStories : Routes.Route() {
val userId = navBackStackEntry.arguments?.getString("id") ?: return@content
val stories = remember { mutableStateListOf<StoryData>() }
val friendInfo = remember { context.database.getFriendInfo(userId) }
val friendInfo by rememberAsyncMutableState(defaultValue = null) {
context.database.getFriendInfo(userId)
}
var lastStoryTimestamp by remember { mutableLongStateOf(Long.MAX_VALUE) }
var selectedStory by remember { mutableStateOf<StoryData?>(null) }

View File

@@ -0,0 +1,30 @@
package me.eternal.purrfectsnap.ui.manager.pages.social
import me.eternal.purrfectsnap.RemoteSideContext
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
import me.eternal.purrfectsnap.storage.getFriends
internal fun RemoteSideContext.sortSocialFriends(
friends: List<MessagingFriendInfo>,
pinnedIds: List<String>? = null
): List<MessagingFriendInfo> {
val whitelistedIds = pinnedIds?.toSet() ?: database.getFriends().map { it.userId }.toSet()
val sortByStreakLength = config.root.userInterface.sortSocialTabByStreakLength.get()
return friends.sortedWith { a, b ->
val aSelected = whitelistedIds.contains(a.userId)
val bSelected = whitelistedIds.contains(b.userId)
if (aSelected != bSelected) {
return@sortedWith if (aSelected) -1 else 1
}
if (sortByStreakLength) {
val aStreak = a.streaks?.length ?: 0
val bStreak = b.streaks?.length ?: 0
if (aStreak != bStreak) return@sortedWith bStreak.compareTo(aStreak)
}
(a.displayName ?: a.mutableUsername).compareTo(b.displayName ?: b.mutableUsername, ignoreCase = true)
}
}

View File

@@ -35,8 +35,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.*
import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
@@ -53,10 +52,32 @@ class SocialRootSection : Routes.Route() {
internal var friendList: List<MessagingFriendInfo> by mutableStateOf(emptyList())
internal var groupList: List<MessagingGroupInfo> by mutableStateOf(emptyList())
internal fun updateScopeLists() {
context.coroutineScope.launch {
friendList = context.database.getFriends(descOrder = true)
groupList = context.database.getGroups()
@Composable
fun SocialDataController() {
LaunchedEffect(Unit) {
// Initial data fetch from the database
withContext(Dispatchers.IO) {
val dbFriends = context.database.getFriends(descOrder = true)
val dbGroups = context.database.getGroups()
val sortedFriends = context.sortSocialFriends(dbFriends)
withContext(Dispatchers.Main) {
friendList = sortedFriends
groupList = dbGroups
}
}
// Real-time synchronization from the bridge
context.database.messagingDataFlow.collect {
withContext(Dispatchers.IO) {
val dbFriends = context.database.getFriends(descOrder = true)
val dbGroups = context.database.getGroups()
val sortedFriends = context.sortSocialFriends(dbFriends)
withContext(Dispatchers.Main) {
friendList = sortedFriends
groupList = dbGroups
}
}
}
}
}
@@ -124,11 +145,6 @@ class SocialRootSection : Routes.Route() {
addFriendDialog?.Content {
addFriendDialog = null
}
DisposableEffect(Unit) {
onDispose {
updateScopeLists()
}
}
}
FloatingActionButton(
@@ -158,8 +174,7 @@ class SocialRootSection : Routes.Route() {
},
getFriendState = { friend -> context.database.getFriendInfo(friend.userId) != null },
getGroupState = { group -> context.database.getGroupInfo(group.conversationId) != null }
),
pinnedIds = (friendList.map { it.userId } + groupList.map { it.conversationId }).reversed(),
)
)
},
modifier = Modifier

View File

@@ -135,13 +135,26 @@ fun HomeAbout.AphelionAboutScreen(nav: NavBackStackEntry) {
color = Color.White,
modifier = Modifier.padding(bottom = 4.dp)
)
Row(
Column(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
DeveloperCard(name = "ΞTΞRNAL", imageRes = R.drawable.pfp_external, avenirNext = avenirNext, modifier = Modifier.weight(1f))
DeveloperCard(name = "<RSR/>", imageRes = R.drawable.pfp_rsr, avenirNext = avenirNext, modifier = Modifier.weight(1f))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically
) {
DeveloperCard(name = "Eternal", subtitle = "", imageRes = R.drawable.pfp_external, avenirNext = avenirNext, modifier = Modifier.weight(1f))
DeveloperCard(name = "Kaladin", subtitle = "", imageRes = R.drawable.pfp_kaladin, avenirNext = avenirNext, modifier = Modifier.weight(1f))
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically
) {
DeveloperCard(name = "schrodingerspet", subtitle = "", imageRes = R.drawable.pfp_schrodingerspet, avenirNext = avenirNext, modifier = Modifier.weight(1f))
DeveloperCard(name = "RSR", subtitle = "", imageRes = R.drawable.pfp_rsr, avenirNext = avenirNext, modifier = Modifier.weight(1f))
}
}
}
}

View File

@@ -44,6 +44,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.lerp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
@@ -63,6 +64,8 @@ import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.data.UpdateDownloader
import me.eternal.purrfectsnap.ui.manager.data.Updater
import me.eternal.purrfectsnap.ui.manager.data.Updater.Channel
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantEntry
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantTriggerStyle
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeRootSection
import me.eternal.purrfectsnap.ui.manager.pages.home.QuickActionsDialog
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
@@ -148,11 +151,15 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
label: String? = null,
contentDescription: String? = label,
shrinkFactor: Float = 1f,
modifier: Modifier = Modifier,
expandedWidth: Dp? = null,
collapsedWidth: Dp = 36.dp,
haptic: HapticFeedback,
onClick: () -> Unit,
) {
val targetWidth = expandedWidth?.let { lerp(collapsedWidth, it, shrinkFactor) }
Surface(
modifier = Modifier.height(36.dp).widthIn(min = 36.dp),
modifier = modifier.height(36.dp).then(if (targetWidth != null) Modifier.width(targetWidth) else Modifier),
shape = RoundedCornerShape(40),
color = Color.White.copy(alpha = 0.06f),
border = BorderStroke(
@@ -167,9 +174,10 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(40))
.clickable { haptic.performHapticFeedback(HapticFeedbackType.LongPress); onClick() }
.padding(vertical = 6.dp, horizontal = lerp(10.dp, 12.dp, shrinkFactor)),
.padding(vertical = 6.dp, horizontal = lerp(7.dp, 10.dp, shrinkFactor)),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
@@ -182,17 +190,19 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
}
)
if (label != null) {
val labelAlpha = (shrinkFactor - 0.1f).coerceIn(0f, 1f)
Spacer(modifier = Modifier.width((8 * shrinkFactor).dp))
Text(
text = label,
color = Color.White.copy(alpha = labelAlpha),
fontSize = 12.sp, fontWeight = FontWeight.Medium,
maxLines = 1, overflow = TextOverflow.Clip,
modifier = Modifier
.graphicsLayer { alpha = labelAlpha; translationX = (-4 * (1f - shrinkFactor)).dp.toPx() }
.widthIn(max = (75 * shrinkFactor).dp)
)
val labelAlpha = ((shrinkFactor - 0.45f) / 0.55f).coerceIn(0f, 1f)
if (labelAlpha > 0.02f) {
Spacer(modifier = Modifier.width((8 * shrinkFactor).dp))
Text(
text = label,
color = Color.White.copy(alpha = labelAlpha),
fontSize = 12.sp, fontWeight = FontWeight.Medium,
maxLines = 1, overflow = TextOverflow.Ellipsis,
modifier = Modifier
.graphicsLayer { alpha = labelAlpha; translationX = (-4 * (1f - shrinkFactor)).dp.toPx() }
.weight(1f, fill = false)
)
}
}
}
}
@@ -206,14 +216,23 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
val shrinkFactor by remember(scrollState.value) {
derivedStateOf { (1f - (scrollState.value.toFloat() / Motion.HEADER_MORPH_THRESHOLD)).coerceIn(0f, 1f) }
}
ManagerAssistantEntry(
context = context,
routes = routes,
style = ManagerAssistantTriggerStyle.APHELION,
shrinkFactor = shrinkFactor,
modifier = Modifier.width(lerp(36.dp, 66.dp, shrinkFactor))
)
AphelionTopBarActionChip(
icon = Icons.Filled.BugReport,
label = context.translation["manager.routes.home_logs"],
expandedWidth = 88.dp,
shrinkFactor = shrinkFactor, haptic = haptic
) { routes.homeLogs.navigate() }
AphelionTopBarActionChip(
icon = Icons.Filled.Settings,
label = context.translation["manager.routes.home_settings"],
expandedWidth = 96.dp,
shrinkFactor = shrinkFactor, haptic = haptic
) { routes.settings.navigate() }
}
@@ -226,7 +245,6 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
downloadState: UpdateDownloader.DownloadState,
downloadProgress: Float,
onUpdateAction: () -> Unit,
channelLabel: String,
isPurrAuraActive: Boolean,
onAboutClick: () -> Unit,
avenirNext: FontFamily,
@@ -283,7 +301,7 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
HeroBadge(translation.format("hero_version_label", "version" to versionName, "channel" to channelLabel))
HeroBadge(translation.format("hero_version_label", "version" to versionName))
gitHashShort.takeIf { it.isNotBlank() && it.lowercase() != "unknown" }?.let {
HeroBadge(translation.format("hero_build_label", "build" to it))
}
@@ -444,17 +462,17 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
}
hasInitialized -> storedTiles
else -> {
context.database.setQuickTiles(allQuickTileNames)
prefs.edit().putBoolean(HomeRootSection.QUICK_TILES_INITIALIZED_PREF, true).apply()
context.coroutineScope.launch(Dispatchers.IO) {
context.database.setQuickTiles(allQuickTileNames)
prefs.edit().putBoolean(HomeRootSection.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"] ?: ""
val latestUpdate by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(updateChannel)) {
Updater.getLatestRelease(if (updateChannel == "prerelease") Channel.PRERELEASE else Channel.STABLE)
val latestUpdate by rememberAsyncMutableState(defaultValue = null) {
Updater.getLatestRelease(Channel.STABLE)
}
val downloadState by UpdateDownloader.downloadState.collectAsState()
val downloadProgress by UpdateDownloader.downloadProgress.collectAsState()
@@ -502,7 +520,7 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
changelogLoading = true
changelogError = null
coroutineScope.launch(Dispatchers.IO) {
val url = if (updateChannel == "prerelease") changelogPrereleaseUrl else changelogStableUrl
val url = changelogStableUrl
runCatching {
OkHttpClient().newCall(Request.Builder().url(url).build()).execute().use { response ->
val body = response.body?.string() ?: throw IllegalStateException("Empty body")
@@ -540,7 +558,7 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
fullChangelogLoading = true
fullChangelogError = null
coroutineScope.launch(Dispatchers.IO) {
val url = if (updateChannel == "prerelease") changelogPrereleaseUrl else changelogStableUrl
val url = changelogStableUrl
runCatching {
OkHttpClient().newCall(Request.Builder().url(url).build()).execute().use { response ->
val body = response.body?.string() ?: throw IllegalStateException("Empty body")
@@ -646,37 +664,27 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
.padding(horizontal = 16.dp)
.height(headerHeight)
) {
Text(
text = "PurrfectSnap",
color = Color.White.copy(alpha = stickyBrandingAlpha),
fontSize = 18.sp, fontWeight = FontWeight.Bold, fontFamily = avenirNext,
modifier = Modifier.align(Alignment.Center)
)
val announcementShift by remember(focusFactor) { derivedStateOf { (-6 * focusFactor).dp } }
Row(
modifier = Modifier.align(Alignment.CenterStart).graphicsLayer { translationX = announcementShift.toPx() },
modifier = Modifier
.align(Alignment.Center)
.wrapContentWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
AphelionTopBarActionChip(
icon = Icons.Filled.Notifications, label = null,
expandedWidth = 52.dp,
shrinkFactor = (1f - focusFactor).coerceIn(0f, 1f),
contentDescription = translation["announcements_button_description"],
haptic = haptic
) { showAnnouncementsDialog = true; loadAnnouncements() }
AphelionTopBarActionChip(
icon = Icons.Filled.Description, label = null,
expandedWidth = 52.dp,
shrinkFactor = (1f - focusFactor).coerceIn(0f, 1f),
contentDescription = translation.getOrNull("changelog_button_description") ?: "Open full changelog",
haptic = haptic
) { showFullChangelogDialog = true; loadFullChangelog() }
}
val settingsShift by remember(focusFactor) { derivedStateOf { (6 * focusFactor).dp } }
Row(
modifier = Modifier.align(Alignment.CenterEnd).graphicsLayer { translationX = settingsShift.toPx() },
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
AphelionHomeActionChips(scrollState = scrollState, haptic = haptic)
}
}
@@ -692,7 +700,6 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
downloadState = downloadState,
downloadProgress = downloadProgress,
onUpdateAction = { latestUpdate?.let { showChangelogDialog = true; loadChangelog() } },
channelLabel = channelLabel,
isPurrAuraActive = isPurrAuraActive,
onAboutClick = { routes.about.navigate() },
avenirNext = avenirNext,

View File

@@ -47,15 +47,11 @@ fun HomeLogs.AphelionLogsScreen(nav: NavBackStackEntry) {
isRefreshing = true
coroutineScope.launch(Dispatchers.IO) {
val readerResult = runCatching {
context.log.newReader { line ->
if (shouldHideLog(line)) return@newReader
coroutineScope.launch(Dispatchers.Main) {
visibleLogs.add(line)
}
}
context.log.newReader { /* items are batch-added from reader logic below */ }
}
readerResult.onFailure {
context.longToast(translation["read_logs_failed_toast"] ?: "Failed to read logs")
withContext(Dispatchers.Main) { isRefreshing = false }
}
readerResult.getOrNull()?.let { reader ->
logReader = reader
@@ -78,52 +74,63 @@ fun HomeLogs.AphelionLogsScreen(nav: NavBackStackEntry) {
fun LogFilterDialog() {
Dialog(onDismissRequest = { showFilterDialog = false }) {
PurrfectOverlayTheme {
PurrfectGlassCard(title = translation["filter_logs_title"] ?: "Filter Log Categories", modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
HomeLogs.LogCategory.entries.forEach { category ->
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable {
enabledCategories.keys.forEach { enabledCategories[it] = false }
enabledCategories[category] = true
refreshLogs()
PurrfectGlassCard(
title = translation["filter_logs_title"] ?: "Log Filters",
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp)
) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.05f))
) {
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
HomeLogs.LogCategory.entries.forEach { category ->
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable {
enabledCategories[category] = !(enabledCategories[category] ?: true)
refreshLogs()
}
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = enabledCategories[category] == true,
onCheckedChange = { checked ->
enabledCategories[category] = checked
refreshLogs()
},
colors = CheckboxDefaults.colors(
checkedColor = PurrfectPalette.glowPrimary,
uncheckedColor = Color.White.copy(alpha = 0.3f),
checkmarkColor = Color.White
)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = translation[category.translationKey] ?: category.name,
color = Color.White,
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold)
)
}
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Checkbox(
checked = enabledCategories[category] == true,
onCheckedChange = { checked ->
enabledCategories[category] = checked
refreshLogs()
},
colors = CheckboxDefaults.colors(
checkedColor = PurrfectPalette.glowPrimary,
uncheckedColor = Color.White.copy(alpha = 0.4f),
checkmarkColor = Color.White
)
)
Text(
text = translation[category.translationKey] ?: category.name,
color = Color.White,
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
Button(
onClick = { showFilterDialog = false },
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(containerColor = PurrfectPalette.glowPrimary)
) {
Text(translation["filter_logs_done_button"] ?: "Done")
}
Button(
onClick = { showFilterDialog = false },
modifier = Modifier.fillMaxWidth().height(54.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = PurrfectPalette.glowPrimary)
) {
Text(translation["filter_logs_done_button"] ?: "Apply Filters", fontWeight = FontWeight.Bold, fontSize = 16.sp)
}
}
}

View File

@@ -2,6 +2,7 @@ package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
import android.content.SharedPreferences
import android.content.Intent
import com.google.gson.JsonParser
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -29,18 +30,31 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.common.action.EnumAction
import me.eternal.purrfectsnap.common.bridge.InternalFileHandleType
import me.eternal.purrfectsnap.common.bridge.wrapper.LoggerConversationExportTarget
import me.eternal.purrfectsnap.common.bridge.wrapper.LoggedMessage
import me.eternal.purrfectsnap.common.data.ContentType
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
import me.eternal.purrfectsnap.core.features.impl.downloader.decoder.DecodedAttachment
import me.eternal.purrfectsnap.core.features.impl.downloader.decoder.MessageDecoder
import me.eternal.purrfectsnap.core.wrapper.impl.getMessageText
import me.eternal.purrfectsnap.storage.findFriend
import me.eternal.purrfectsnap.storage.getAllScopeNotes
import me.eternal.purrfectsnap.storage.getFriendInfo
import me.eternal.purrfectsnap.storage.getGroupInfo
import me.eternal.purrfectsnap.storage.setAllScopeNotes
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
@@ -60,6 +74,8 @@ import androidx.compose.ui.platform.LocalView
import androidx.core.view.drawToBitmap
import java.io.File
import java.net.URLEncoder
import java.text.DateFormat
import java.util.Date
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -222,22 +238,12 @@ fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
RowTitle(title = translation["updates_title"])
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
var autoUpdateCheck by remember { mutableStateOf(context.config.root.global.updateSettings.autoUpdateCheck.getNullable() ?: true) }
var selectedChannel by remember { mutableStateOf(context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable") }
var channelMenuExpanded by remember { mutableStateOf(false) }
ShiftedRow {
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Text(text = translation["auto_update_check"], fontSize = 14.sp)
Switch(checked = autoUpdateCheck, onCheckedChange = { if (context.config.root.global.uiSettings.hapticFeedback.get()) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); autoUpdateCheck = it; context.config.root.global.updateSettings.autoUpdateCheck.set(it); context.config.writeConfig(); scheduleUpdateCheck() }, modifier = Modifier.padding(end = 26.dp), colors = purrfectSwitchColors())
}
}
AnimatedVisibility(visible = autoUpdateCheck) {
ExposedDropdownMenuBox(expanded = channelMenuExpanded, onExpandedChange = { channelMenuExpanded = it }, modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp)) {
AestheticDropdownField(value = translation.getOrNull("update_channel_${selectedChannel}") ?: selectedChannel, expanded = channelMenuExpanded, modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), onClick = { channelMenuExpanded = true })
ExposedDropdownMenu(expanded = channelMenuExpanded, onDismissRequest = { channelMenuExpanded = false }) {
listOf("stable", "prerelease").forEach { channel -> DropdownMenuItem(text = { Text(text = translation.getOrNull("update_channel_${channel}") ?: channel) }, onClick = { selectedChannel = channel; channelMenuExpanded = false; context.config.root.global.updateSettings.updateChannel.set(channel); context.config.writeConfig(); scheduleUpdateCheck() }) }
}
}
}
}
}
@@ -257,11 +263,494 @@ fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
var storedMessagesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredMessageCount() }
var storedStoriesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredStoriesCount() }
var showImportDialog by remember { mutableStateOf(false) }
var showExportOptionsDialog by remember { mutableStateOf(false) }
var showConversationExportDialog by remember { mutableStateOf(false) }
var showConversationFormatDialog by remember { mutableStateOf(false) }
var conversationSearchQuery by remember { mutableStateOf("") }
var selectedConversationForExport by remember { mutableStateOf<LoggerConversationExportTarget?>(null) }
var pendingConversationExportTarget by remember { mutableStateOf<LoggerConversationExportTarget?>(null) }
val loggerHistoryTranslation = remember { context.translation.getCategory("logger_history") }
data class ConversationSearchTarget(
val target: LoggerConversationExportTarget,
val friendDisplayName: String?,
val friendUsername: String?,
val chatDisplayName: String?,
val groupDisplayName: String?,
val readableUsernames: List<String>,
val readableIdentifiers: List<String>,
val isDirectChat: Boolean,
val isGroupChat: Boolean,
val sortOrder: Int
)
data class ConversationExportFormat(
val extension: String,
val mimeType: String,
val label: String
)
data class ParsedConversationMessage(
val senderId: String,
val senderUsername: String,
val timestamp: Long,
val contentType: ContentType,
val messageText: String?,
val attachments: List<DecodedAttachment>
)
fun String.isUuidLike(): Boolean {
val value = trim()
if (value.length != 36) return false
if (value[8] != '-' || value[13] != '-' || value[18] != '-' || value[23] != '-') return false
return value.filterIndexed { index, _ ->
index != 8 && index != 13 && index != 18 && index != 23
}.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' }
}
fun String.isLikelyInternalId(): Boolean {
val value = trim()
if (value.isUuidLike()) return true
if (value.length >= 10 && value.all(Char::isDigit)) return true
if (value.length >= 16 && value.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' || it == '-' }) {
val digitCount = value.count(Char::isDigit)
val alphaCount = value.count { it.lowercaseChar() in 'a'..'f' }
if (digitCount >= 4 && alphaCount >= 4) return true
}
return false
}
fun String.toReadableIdentityOrNull(): String? {
val value = trim()
if (value.isEmpty()) return null
if (value.isLikelyInternalId()) return null
if (!value.any { it.isLetter() }) return null
return value
}
fun String.toSearchIdentityOrNull(): String? {
val value = trim()
if (value.isEmpty()) return null
if (value.equals("myai", ignoreCase = true)) return null
return value
}
val exportTargets by rememberAsyncMutableState(defaultValue = emptyList<LoggerConversationExportTarget>()) {
context.messageLogger.getConversationExportTargets()
}
val exportSearchTargets by rememberAsyncMutableState(
defaultValue = emptyList<ConversationSearchTarget>(),
keys = arrayOf(exportTargets)
) {
val friendIdentityCache = mutableMapOf<String, Pair<String?, String?>?>()
exportTargets.mapIndexedNotNull { index, target ->
val friend = context.database.findFriend(target.conversationId)
val group = context.database.getGroupInfo(target.conversationId)
val chatDisplayName = target.groupTitle
?.toReadableIdentityOrNull()
?.takeIf { !it.equals(target.conversationId, ignoreCase = true) }
val friendDisplayName = friend?.displayName?.toReadableIdentityOrNull()
val friendUsername = friend?.mutableUsername?.toReadableIdentityOrNull()
val searchableUsernames = target.usernames
.mapNotNull { it.toSearchIdentityOrNull() }
.distinct()
val readableUsernames = searchableUsernames
.mapNotNull { it.toReadableIdentityOrNull() }
.distinct()
val hasManyParticipants = target.userIds.distinct().size > 2 || searchableUsernames.size > 2
val fallbackFriendIdentities = if (friend == null && !hasManyParticipants) {
target.userIds.mapNotNull { userId ->
friendIdentityCache.getOrPut(userId) {
context.database.getFriendInfo(userId)?.let {
it.displayName?.toReadableIdentityOrNull() to
it.mutableUsername.toReadableIdentityOrNull()
}
}?.takeIf { it.first != null || it.second != null }
}
} else {
emptyList()
}
val fallbackFriendDisplayName = fallbackFriendIdentities.firstNotNullOfOrNull { it.first }
val fallbackFriendUsername = fallbackFriendIdentities.firstNotNullOfOrNull { it.second }
val resolvedFriendDisplayName = friendDisplayName ?: fallbackFriendDisplayName
val resolvedFriendUsername = friendUsername ?: fallbackFriendUsername
val groupDisplayName = group?.name?.toReadableIdentityOrNull()
?: chatDisplayName?.takeIf { hasManyParticipants }
val isGroupChat = groupDisplayName != null || hasManyParticipants
val isDirectChat = !isGroupChat
val readableIdentifiers = buildList {
add(target.conversationId)
addAll(target.userIds)
resolvedFriendDisplayName?.let { add(it) }
resolvedFriendUsername?.let { add(it) }
chatDisplayName?.let { add(it) }
groupDisplayName?.let { add(it) }
addAll(searchableUsernames)
addAll(readableUsernames)
}.distinct()
ConversationSearchTarget(
target = target,
friendDisplayName = resolvedFriendDisplayName,
friendUsername = resolvedFriendUsername,
chatDisplayName = chatDisplayName,
groupDisplayName = groupDisplayName,
readableUsernames = readableUsernames,
readableIdentifiers = readableIdentifiers,
isDirectChat = isDirectChat,
isGroupChat = isGroupChat,
sortOrder = index
)
}.sortedWith(
compareBy<ConversationSearchTarget> {
when {
it.isDirectChat -> 0
it.isGroupChat -> 1
else -> 2
}
}.thenBy { it.sortOrder }
)
}
val filteredExportTargets = remember(exportSearchTargets, conversationSearchQuery) {
val query = conversationSearchQuery.trim()
if (query.isBlank()) {
exportSearchTargets
} else {
exportSearchTargets.filter { searchTarget ->
searchTarget.readableIdentifiers.any {
it.contains(query, ignoreCase = true)
}
}
}
}
val exportFormats = remember {
listOf(
ConversationExportFormat("db", "application/octet-stream", ".db"),
ConversationExportFormat("html", "text/html", "HTML"),
ConversationExportFormat("txt", "text/plain", "TXT")
)
}
fun formatExportTarget(searchTarget: ConversationSearchTarget): String {
searchTarget.friendDisplayName?.let { displayName ->
val username = searchTarget.friendUsername
val formattedName = if (username != null && !username.equals(displayName, ignoreCase = true)) {
"$displayName • @$username"
} else {
displayName
}
return loggerHistoryTranslation.format("list_friend_format", "name" to formattedName)
}
searchTarget.friendUsername?.let { username ->
return loggerHistoryTranslation.format("list_friend_format", "name" to "@$username")
}
searchTarget.chatDisplayName?.takeIf { searchTarget.isDirectChat }?.let {
return loggerHistoryTranslation.format("list_friend_format", "name" to it)
}
if (searchTarget.isDirectChat && searchTarget.readableUsernames.isNotEmpty()) {
val friendName = if (searchTarget.readableUsernames.size == 1) {
searchTarget.readableUsernames.first()
} else {
searchTarget.readableUsernames.joinToString(", ")
}
return loggerHistoryTranslation.format("list_friend_format", "name" to friendName)
}
searchTarget.groupDisplayName?.let {
return loggerHistoryTranslation.format("list_group_format", "name" to it)
}
if (searchTarget.readableUsernames.isNotEmpty()) {
return loggerHistoryTranslation.format(
"list_group_format",
"name" to searchTarget.readableUsernames.joinToString(", ")
)
}
return if (searchTarget.isGroupChat) {
loggerHistoryTranslation.format("list_group_format", "name" to searchTarget.target.conversationId)
} else {
loggerHistoryTranslation.format("list_friend_format", "name" to searchTarget.target.conversationId)
}
}
fun showExportError(throwable: Throwable) {
context.log.error("Failed to export message logger", throwable)
context.shortToast(
translation.format(
"message_logger_export_failed_toast",
"message" to (throwable.message ?: "Unknown error")
)
)
}
fun showImportError(throwable: Throwable) {
context.log.error("Failed to import message logger", throwable)
context.shortToast(
translation.format(
"import_failed_toast",
"message" to (throwable.message ?: "Unknown error")
)
)
}
fun parseConversationMessage(message: LoggedMessage): ParsedConversationMessage {
val messageObject = runCatching {
JsonParser.parseString(String(message.messageData, Charsets.UTF_8)).asJsonObject
}.getOrNull()
val messageContent = messageObject?.getAsJsonObject("mMessageContent")
val contentBytes = runCatching {
messageContent?.getAsJsonArray("mContent")?.map { it.asByte }?.toByteArray()
}.getOrNull()
val contentType = messageContent?.getAsJsonPrimitive("mContentType")?.asString?.let {
runCatching { ContentType.valueOf(it) }.getOrNull()
} ?: contentBytes?.let { ContentType.fromMessageContainer(ProtoReader(it)) } ?: ContentType.UNKNOWN
val messageText = contentBytes?.getMessageText(contentType)
val attachments = runCatching {
messageContent?.let { MessageDecoder.decode(it) } ?: emptyList()
}.getOrDefault(emptyList())
return ParsedConversationMessage(
senderId = message.userId,
senderUsername = message.username,
timestamp = message.sendTimestamp,
contentType = contentType,
messageText = messageText,
attachments = attachments
)
}
fun htmlEscape(input: String): String {
val escaped = StringBuilder(input.length)
input.forEach { char ->
when (char) {
'&' -> escaped.append("&amp;")
'<' -> escaped.append("&lt;")
'>' -> escaped.append("&gt;")
'"' -> escaped.append("&quot;")
'\'' -> escaped.append("&#39;")
else -> escaped.append(char)
}
}
return escaped.toString()
}
fun writeConversationExportFile(
target: LoggerConversationExportTarget,
format: ConversationExportFormat,
outputFile: File
): Int {
val conversationId = target.conversationId.trim()
if (conversationId.isEmpty()) {
throw IllegalArgumentException("Conversation ID cannot be empty")
}
val searchTarget = exportSearchTargets.firstOrNull { it.target.conversationId == conversationId }
val conversationTitle = searchTarget?.let { formatExportTarget(it) }
?: (translation["message_logger_export_individual_chat"] ?: "Exported Chat")
val dateFormatter = DateFormat.getDateTimeInstance()
val senderCache = mutableMapOf<String, String>()
fun formatSenderLabel(senderId: String, senderUsername: String): String {
val friendInfo = context.database.getFriendInfo(senderId)
val senderDisplayName = friendInfo?.displayName?.toReadableIdentityOrNull()
val senderReadableUsername = friendInfo?.mutableUsername?.toReadableIdentityOrNull()
?: senderUsername.toReadableIdentityOrNull()
return when {
senderDisplayName != null &&
senderReadableUsername != null &&
!senderDisplayName.equals(senderReadableUsername, ignoreCase = true) ->
"$senderDisplayName (@$senderReadableUsername)"
senderDisplayName != null -> senderDisplayName
senderReadableUsername != null -> "@$senderReadableUsername"
else -> translation["sender_unknown"] ?: "Unknown sender"
}
}
outputFile.parentFile?.mkdirs()
if (outputFile.exists() && !outputFile.delete()) {
throw IllegalStateException("Failed to prepare export file")
}
return outputFile.bufferedWriter(Charsets.UTF_8).use { writer ->
val isHtmlFormat = format.extension == "html"
if (isHtmlFormat) {
writer.appendLine("<!DOCTYPE html>")
writer.appendLine("<html><head><meta charset=\"UTF-8\" />")
writer.appendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />")
writer.appendLine("<title>${htmlEscape(conversationTitle)}</title>")
writer.appendLine(
"<style>body{font-family:Arial,sans-serif;background:#121212;color:#f3f3f3;padding:16px;}h2{margin-top:0;}" +
".meta{color:#a5a5a5;font-size:12px;margin-bottom:4px;}" +
".message{border:1px solid #2d2d2d;border-radius:10px;padding:10px;margin:10px 0;background:#1b1b1b;}" +
".content{white-space:pre-wrap;word-break:break-word;}" +
".attachments{margin:8px 0 0 18px;padding:0;}a{color:#8db7ff;}</style>"
)
writer.appendLine("</head><body>")
writer.appendLine("<h2>${htmlEscape(conversationTitle)}</h2>")
writer.appendLine("<p>${htmlEscape(translation.format("message_logger_conversation_id", "id" to conversationId))}</p>")
} else {
writer.appendLine(conversationTitle)
writer.appendLine("")
}
val exportedMessageCount = context.messageLogger.forEachConversationMessage(
conversationId = conversationId,
userIds = target.userIds,
orderAscending = true
) { loggedMessage ->
val parsed = parseConversationMessage(loggedMessage)
val senderInfo = senderCache.getOrPut(parsed.senderId) {
formatSenderLabel(parsed.senderId, parsed.senderUsername)
}
val senderLabel = senderInfo
val content = parsed.messageText?.takeIf { it.isNotBlank() } ?: if (parsed.contentType == ContentType.CHAT) {
loggerHistoryTranslation["empty_message"]
} else {
parsed.contentType.name.lowercase()
}
if (isHtmlFormat) {
writer.appendLine("<div class=\"message\">")
writer.appendLine(
"<div class=\"meta\">${
htmlEscape(
"${dateFormatter.format(Date(parsed.timestamp))}$senderLabel${
parsed.contentType.name.lowercase()
}"
)
}</div>"
)
writer.appendLine("<div class=\"content\">${htmlEscape(content).replace("\n", "<br/>")}</div>")
if (parsed.attachments.isNotEmpty()) {
writer.appendLine("<ul class=\"attachments\">")
parsed.attachments.forEachIndexed { index, attachment ->
val attachmentLabel = "${loggerHistoryTranslation.format("chat_attachment", "index" to (index + 1).toString())} [${attachment.type.name.lowercase()}]"
val directUrl = attachment.directUrl?.takeIf { it.isNotBlank() }
if (directUrl != null) {
writer.appendLine(
"<li><a href=\"${htmlEscape(directUrl)}\" target=\"_blank\" rel=\"noopener noreferrer\">${
htmlEscape(attachmentLabel)
}</a></li>"
)
} else {
val placeholder = attachment.boltKey?.takeIf { it.isNotBlank() }
?: attachment.mediaUniqueId?.takeIf { it.isNotBlank() }
?: (translation["message_logger_missing_attachment_placeholder"] ?: "Attachment unavailable")
writer.appendLine("<li>${htmlEscape("$attachmentLabel: $placeholder")}</li>")
}
}
writer.appendLine("</ul>")
}
writer.appendLine("</div>")
} else {
writer.appendLine("[${dateFormatter.format(Date(parsed.timestamp))}] $senderLabel: $content")
parsed.attachments.forEachIndexed { index, attachment ->
val attachmentLabel = "${loggerHistoryTranslation.format("chat_attachment", "index" to (index + 1).toString())} [${attachment.type.name.lowercase()}]"
val attachmentValue = attachment.directUrl?.takeIf { it.isNotBlank() }
?: (translation["message_logger_missing_attachment_placeholder"] ?: "Attachment unavailable")
writer.appendLine(" - $attachmentLabel: $attachmentValue")
}
writer.appendLine("")
}
}
if (exportedMessageCount == 0) {
if (isHtmlFormat) {
writer.appendLine("<p>${htmlEscape(translation["message_logger_no_messages_export_text"] ?: "No messages found in this chat.")}</p>")
} else {
writer.appendLine(translation["message_logger_no_messages_export_text"] ?: "No messages found in this chat.")
}
}
if (isHtmlFormat) {
writer.appendLine("</body></html>")
}
exportedMessageCount
}
}
fun exportFullDatabase() {
runCatching {
activityLauncherHelper.saveFile("message_logger.db", "application/octet-stream") { uri ->
context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { out ->
context.messageLogger.databaseFile.inputStream().use { input -> input.copyTo(out) }
} ?: throw IllegalStateException("Failed to open output stream")
}
}.onFailure { showExportError(it) }
}
fun exportConversation(target: LoggerConversationExportTarget, format: ConversationExportFormat) {
val conversationId = target.conversationId.trim()
if (conversationId.isEmpty()) {
context.shortToast(translation["message_logger_missing_conversation_toast"])
return
}
val fileNameSuffix = conversationId
.filter { it.isLetterOrDigit() || it == '-' || it == '_' }
.take(24)
.ifBlank { "chat" }
runCatching {
activityLauncherHelper.saveFile("message_logger_${fileNameSuffix}.${format.extension}", format.mimeType) { uri ->
scope.launch {
runCatching {
val exportedMessageCount = withContext(Dispatchers.IO) {
val tempFile = File(
context.androidContext.cacheDir,
"message_logger_export_${System.currentTimeMillis()}.${format.extension}"
)
try {
val messageCount = if (format.extension == "db") {
context.messageLogger.exportConversationDatabase(
outputFile = tempFile,
conversationId = conversationId,
userIds = target.userIds
).messageCount
} else {
writeConversationExportFile(target, format, tempFile)
}
context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { output ->
tempFile.inputStream().use { input -> input.copyTo(output) }
} ?: throw IllegalStateException("Failed to open output stream")
messageCount
} finally {
tempFile.delete()
}
}
if (exportedMessageCount == 0) {
context.shortToast(translation["message_logger_empty_chat_toast"])
} else {
context.shortToast(translation["success_toast"])
}
}.onFailure { showExportError(it) }
}
}
}.onFailure { showExportError(it) }
}
fun dismissConversationExportDialog() {
showConversationExportDialog = false
selectedConversationForExport = null
conversationSearchQuery = ""
}
fun dismissConversationFormatDialog() {
showConversationFormatDialog = false
pendingConversationExportTarget = null
}
Column(modifier = Modifier.fillMaxWidth().padding(5.dp), verticalArrangement = Arrangement.spacedBy(10.dp), horizontalAlignment = Alignment.CenterHorizontally) {
val summary = translation.format("message_logger_summary", "messageCount" to storedMessagesCount.toString(), "storyCount" to storedStoriesCount.toString()).replace("\n", " | ")
Text(summary, maxLines = 2, color = Color.White, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth())
FlowRow(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(10.dp) ) {
Button(onClick = { runCatching { activityLauncherHelper.saveFile("message_logger.db", "application/octet-stream") { uri -> context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { out -> context.messageLogger.databaseFile.inputStream().use { it.copyTo(out) } } } }.onFailure { context.log.error("Failed to export", it) } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["export_button"]) }
Button(onClick = { showExportOptionsDialog = true }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["export_button"]) }
Button(onClick = { runCatching { activityLauncherHelper.openFile("application/octet-stream") { uri -> val tempFile = File(context.androidContext.cacheDir, "view_logger.db"); context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { it.copyTo(tempFile.outputStream()) }; routes.viewLoggerHistory.navigate { put("uri", URLEncoder.encode(tempFile.toUri().toString(), "UTF-8")) } } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["view_button"]) }
Button(onClick = { runCatching { context.messageLogger.purgeAll(); storedMessagesCount = 0; storedStoriesCount = 0 }.onSuccess { context.shortToast(translation["success_toast"]) } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["clear_button"]) }
Button(onClick = { showImportDialog = true }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["import_button"]) }
@@ -269,7 +758,222 @@ fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
}
OutlinedButton(modifier = Modifier.fillMaxWidth().padding(5.dp), onClick = { routes.loggerHistory.navigate() }, colors = sharedOutlinedColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.2f))) { Text(translation["view_logger_history_button"]) }
if (showImportDialog) {
AestheticDialog(onDismissRequest = { showImportDialog = false }, title = translation["message_logger_import_title"], text = translation["message_logger_import_text"], icon = Icons.Filled.Info, confirmButtonText = context.translation["button.import"], dismissButtonText = context.translation["button.cancel"], onConfirm = { showImportDialog = false; runCatching { activityLauncherHelper.openFile("application/octet-stream") { uri -> context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { context.messageLogger.databaseFile.outputStream().use { out -> it.copyTo(out) } }; storedMessagesCount = context.messageLogger.getStoredMessageCount(); storedStoriesCount = context.messageLogger.getStoredStoriesCount(); context.shortToast(translation["success_toast"]) } } }, onDismiss = { showImportDialog = false }, showCloseButton = false)
AestheticDialog(
onDismissRequest = { showImportDialog = false },
title = translation["message_logger_import_title"],
text = translation["message_logger_import_text"],
icon = Icons.Filled.Info,
confirmButtonText = context.translation["button.import"],
dismissButtonText = context.translation["button.cancel"],
onConfirm = {
showImportDialog = false
runCatching {
activityLauncherHelper.openFile("application/octet-stream") { uri ->
scope.launch {
runCatching {
val importResult = withContext(Dispatchers.IO) {
context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { input ->
context.messageLogger.importDatabase(input)
} ?: throw IllegalStateException("Failed to open selected backup file")
}
storedMessagesCount = importResult.messageCount
storedStoriesCount = importResult.storyCount
context.shortToast(translation["success_toast"])
}.onFailure { showImportError(it) }
}
}
}.onFailure { showImportError(it) }
},
onDismiss = { showImportDialog = false },
showCloseButton = false
)
}
if (showExportOptionsDialog) {
AestheticDialog(
onDismissRequest = { showExportOptionsDialog = false },
title = translation["message_logger_export_title"] ?: "Export Message Logger",
text = translation["message_logger_export_text"] ?: "Choose what to export.",
icon = Icons.Filled.SaveAlt,
confirmButtonText = context.translation["button.cancel"],
onConfirm = { showExportOptionsDialog = false },
showCloseButton = false,
customContent = {
Button(
onClick = {
showExportOptionsDialog = false
pendingConversationExportTarget = null
showConversationExportDialog = true
},
modifier = Modifier.fillMaxWidth(),
colors = sharedButtonColors,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Text(translation["message_logger_export_individual_chat"] ?: "Export Individual Chat")
}
Button(
onClick = {
showExportOptionsDialog = false
exportFullDatabase()
},
modifier = Modifier.fillMaxWidth(),
colors = sharedButtonColors,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Text(translation["message_logger_export_full_database"] ?: "Export Full Database")
}
}
)
}
if (showConversationExportDialog) {
AestheticDialog(
onDismissRequest = { dismissConversationExportDialog() },
title = translation["message_logger_select_chat_title"] ?: "Export Individual Chat",
text = translation["message_logger_select_chat_text"] ?: "Search by username, display name, or chat name.",
icon = Icons.Filled.Search,
confirmButtonText = translation["message_logger_continue_button"] ?: "Continue",
dismissButtonText = context.translation["button.cancel"],
onConfirm = {
val selectedTarget = selectedConversationForExport ?: return@AestheticDialog
pendingConversationExportTarget = selectedTarget
dismissConversationExportDialog()
showConversationFormatDialog = true
},
onDismiss = { dismissConversationExportDialog() },
showCloseButton = false,
confirmEnabled = selectedConversationForExport != null,
customContent = {
OutlinedTextField(
value = conversationSearchQuery,
onValueChange = { conversationSearchQuery = it },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(context.translation["manager.dialogs.add_friend.search_hint"] ?: "Search")
},
leadingIcon = {
Icon(Icons.Filled.Search, contentDescription = null)
},
trailingIcon = if (conversationSearchQuery.isNotBlank()) {
{
IconButton(onClick = { conversationSearchQuery = "" }) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = null
)
}
}
} else null,
colors = TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedContainerColor = Color.White.copy(alpha = 0.08f),
unfocusedContainerColor = Color.White.copy(alpha = 0.06f),
focusedTextColor = Color.White,
unfocusedTextColor = Color.White
)
)
if (filteredExportTargets.isEmpty()) {
Text(
text = translation["message_logger_no_chats_found"] ?: "No chats found",
color = PurrfectPalette.textSecondary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
} else {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 280.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(filteredExportTargets.size) { index ->
val searchTarget = filteredExportTargets[index]
val target = searchTarget.target
val isSelected = selectedConversationForExport?.conversationId == searchTarget.target.conversationId
OutlinedButton(
onClick = { selectedConversationForExport = searchTarget.target },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White),
border = BorderStroke(
1.dp,
if (isSelected) {
PurrfectPalette.glowPrimary.copy(alpha = 0.55f)
} else {
Color.White.copy(alpha = 0.18f)
}
)
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = formatExportTarget(searchTarget),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
val secondaryLabel = when {
searchTarget.friendDisplayName != null && searchTarget.friendUsername != null -> "@${searchTarget.friendUsername}"
searchTarget.friendDisplayName != null -> searchTarget.friendDisplayName
searchTarget.chatDisplayName != null -> searchTarget.chatDisplayName
searchTarget.groupDisplayName != null -> searchTarget.groupDisplayName
searchTarget.readableUsernames.isNotEmpty() -> searchTarget.readableUsernames.joinToString(", ")
else -> null
}
if (secondaryLabel != null) {
Text(
text = secondaryLabel,
color = PurrfectPalette.textSecondary,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Text(
text = translation.format("message_logger_message_count", "count" to target.messageCount.toString()),
color = PurrfectPalette.textSecondary,
fontSize = 12.sp
)
}
}
}
}
}
}
)
}
if (showConversationFormatDialog && pendingConversationExportTarget != null) {
AestheticDialog(
onDismissRequest = { dismissConversationFormatDialog() },
title = translation["message_logger_select_export_format_title"] ?: "Select Export Format",
text = translation["message_logger_select_export_format_text"] ?: "Choose how to export the selected chat.",
icon = Icons.Filled.Description,
confirmButtonText = context.translation["button.cancel"],
onConfirm = { dismissConversationFormatDialog() },
showCloseButton = false,
customContent = {
exportFormats.forEach { format ->
val formatLabel = when (format.extension) {
"db" -> translation["message_logger_export_format_db"] ?: ".db"
"html" -> translation["message_logger_export_format_html"] ?: "HTML"
else -> translation["message_logger_export_format_txt"] ?: "TXT"
}
Button(
onClick = {
val exportTarget = pendingConversationExportTarget ?: return@Button
dismissConversationFormatDialog()
exportConversation(exportTarget, format)
},
modifier = Modifier.fillMaxWidth(),
colors = sharedButtonColors,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Text(formatLabel)
}
}
}
)
}
}
}

View File

@@ -39,15 +39,20 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.common.data.SocialScope
import me.eternal.purrfectsnap.ui.manager.pages.social.SocialRootSection
import me.eternal.purrfectsnap.ui.manager.pages.social.sortSocialFriends
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SocialRootSection.AphelionSocialScreen(nav: NavBackStackEntry) {
// Controller handles data loading and synchronization
SocialDataController()
val titles = remember {
listOf(translation["friends_tab"], translation["groups_tab"])
}
@@ -56,19 +61,9 @@ fun SocialRootSection.AphelionSocialScreen(nav: NavBackStackEntry) {
var searchQuery by rememberSaveable { mutableStateOf("") }
var searchActive by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(Unit) {
context.database.receiveMessagingDataCallback = { friends, groups ->
friendList = friends
groupList = groups
}
updateScopeLists()
}
DisposableEffect(Unit) {
onDispose {
context.database.receiveMessagingDataCallback = { _, _ -> }
}
}
val normalizedQuery = remember(searchQuery) { searchQuery.trim() }
// Filter logic based on the parent's synchronized data lists
val filteredFriends = remember(friendList, normalizedQuery) {
if (normalizedQuery.isBlank()) {
friendList

View File

@@ -269,8 +269,9 @@ class ManageFriendTrackerReposSection: Routes.Route() {
}
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
val repositories by remember(refreshTrigger.value) {
mutableStateOf<List<String>>(runBlocking { context.database.getRepositories("friend_tracker") })
var repositories by remember { mutableStateOf<List<String>>(emptyList()) }
LaunchedEffect(refreshTrigger.value) {
repositories = context.database.getRepositories("friend_tracker")
}
val density = LocalDensity.current
val statusBarTopPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()

View File

@@ -56,6 +56,7 @@ import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Flag
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.SmartToy
import androidx.compose.material.icons.filled.VerifiedUser
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Icon
@@ -93,6 +94,8 @@ import androidx.navigation.compose.rememberNavController
import me.eternal.purrfectsnap.RemoteSideContext
import me.eternal.purrfectsnap.SharedContextHolder
import me.eternal.purrfectsnap.common.ui.AppMaterialTheme
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantDialog
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.screens.SetupScreen
@@ -104,6 +107,8 @@ import me.eternal.purrfectsnap.ui.setup.screens.impl.PickLanguageScreen
import me.eternal.purrfectsnap.ui.setup.screens.impl.PatchSnapchatScreen
import me.eternal.purrfectsnap.ui.setup.screens.impl.RootInstallSnapchatScreen
import me.eternal.purrfectsnap.ui.setup.screens.impl.SaveFolderScreen
import me.eternal.purrfectsnap.ui.setup.screens.impl.IntroShowcaseScreen
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
import me.eternal.purrfectsnap.ui.util.scaleOnPress
import kotlinx.coroutines.delay
@@ -127,6 +132,9 @@ class SetupActivity : ComponentActivity() {
}
val requirements = intent.getIntExtra("requirements", Requirements.FIRST_RUN)
val setupPrefs = setupContext.sharedPreferences
val setupRoutes = Routes(setupContext).apply {
activityLauncher = ActivityLauncherHelper(this@SetupActivity)
}
fun hasRequirement(requirement: Int) = requirements and requirement == requirement
val wasInProgress = setupPrefs.getBoolean("setup_in_progress", false)
val isFirstRunFlow = hasRequirement(Requirements.FIRST_RUN) || wasInProgress
@@ -159,6 +167,7 @@ class SetupActivity : ComponentActivity() {
val requiredScreens = mutableListOf<SetupScreen>().apply {
if (isFirstRunFlow || hasRequirement(Requirements.LANGUAGE)) {
add(IntroShowcaseScreen().apply { route = "introShowcase" })
add(PickLanguageScreen().apply { route = "language" })
if (isFirstRunFlow) {
add(InstallModeScreen(
@@ -315,19 +324,7 @@ class SetupActivity : ComponentActivity() {
AppMaterialTheme {
val view = LocalView.current
val navBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
var showImportantDialog by rememberSaveable {
mutableStateOf(!setupPrefs.getBoolean("setup_important_notice_shown", false))
}
var importantTimeout by remember { mutableIntStateOf(5) }
LaunchedEffect(showImportantDialog) {
if (showImportantDialog) {
importantTimeout = 5
while (importantTimeout > 0) {
delay(1000)
importantTimeout--
}
}
}
var setupAiPrompt by rememberSaveable { mutableStateOf<String?>(null) }
SideEffect {
val window = (view.context as Activity).window
WindowCompat.setDecorFitsSystemWindows(window, false)
@@ -344,46 +341,8 @@ class SetupActivity : ComponentActivity() {
.fillMaxSize()
.background(Color.Transparent)
) {
if (showImportantDialog) {
val confirmLabel = if (importantTimeout > 0) {
translation.format(
"setup.activity.important_confirm_timeout",
"seconds" to importantTimeout.toString()
)
} else {
translation["setup.activity.important_confirm"]
}
AestheticDialog(
onDismissRequest = {
if (importantTimeout == 0) {
showImportantDialog = false
setupPrefs.edit().putBoolean("setup_important_notice_shown", true).apply()
}
},
title = translation["setup.activity.important_title"],
text = "",
icon = Icons.Filled.Warning,
confirmButtonText = confirmLabel,
onConfirm = {
if (importantTimeout == 0) {
showImportantDialog = false
setupPrefs.edit().putBoolean("setup_important_notice_shown", true).apply()
}
},
confirmEnabled = importantTimeout == 0,
showCloseButton = false,
customContent = {
Text(
text = translation["setup.activity.important_message"],
color = PurrfectPalette.textSecondary,
lineHeight = 18.sp
)
},
opaque = true
)
}
SetupAuroraBackground()
SetupTopBar()
SetupTopBar(onAskAi = { setupAiPrompt = "hi" })
val bottomPadding = 118.dp + navBarPadding
Column(
modifier = Modifier
@@ -468,6 +427,14 @@ class SetupActivity : ComponentActivity() {
.navigationBarsPadding()
.padding(bottom = 32.dp)
)
setupAiPrompt?.let { prompt ->
ManagerAssistantDialog(
context = setupContext,
routes = setupRoutes,
initialUserMessage = prompt,
onDismiss = { setupAiPrompt = null }
)
}
}
}
}
@@ -483,6 +450,12 @@ private fun SetupScreen.meta(context: RemoteSideContext): SetupStepMeta {
subtitle = translation["setup.activity.language_subtitle"],
icon = Icons.Filled.Language
)
is IntroShowcaseScreen -> SetupStepMeta(
route = route,
title = "Welcome",
subtitle = "Preview what PurrfectSnap can do",
icon = Icons.Filled.AutoAwesome
)
is InstallModeScreen -> SetupStepMeta(
route = route,
@@ -587,7 +560,7 @@ private fun SetupAuroraBackground() {
}
@Composable
private fun SetupTopBar() {
private fun SetupTopBar(onAskAi: () -> Unit) {
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
Surface(
modifier = Modifier
@@ -613,14 +586,37 @@ private fun SetupTopBar() {
.fillMaxWidth()
.padding(horizontal = 18.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "PurrfectSnap",
color = PurrfectPalette.textPrimary,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
fontSize = 18.sp,
modifier = Modifier.weight(1f)
)
Surface(
shape = RoundedCornerShape(40),
color = Color.White.copy(alpha = 0.08f),
border = androidx.compose.foundation.BorderStroke(1.dp, Color.White.copy(alpha = 0.14f))
) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(40))
.clickable(onClick = onAskAi)
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(Icons.Filled.SmartToy, contentDescription = null, tint = Color.White)
Text(
text = "Ask AI",
color = Color.White,
fontWeight = FontWeight.Medium,
fontSize = 13.sp
)
}
}
}
}
}

View File

@@ -0,0 +1,112 @@
package me.eternal.purrfectsnap.ui.setup.screens.impl
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
class IntroShowcaseScreen : SetupScreen() {
private val slides = listOf(
R.drawable.setup_slide_plus to "Unlock Snapchat Plus for free!",
R.drawable.setup_slide_upload_tag to "Bypass the Media Upload tag!",
R.drawable.setup_slide_downloads to "Download Snaps, & Spotlights!"
)
@Composable
override fun Content() {
LaunchedEffect(Unit) { allowNext(true) }
var currentIndex by remember { mutableIntStateOf(0) }
LaunchedEffect(Unit) {
while (true) {
delay(5000)
currentIndex = (currentIndex + 1) % slides.size
}
}
SetupCard {
StepTitle(
title = "Welcome to PurrfectSnap",
subtitle = "A quick look before setup begins",
modifier = Modifier.align(Alignment.CenterHorizontally),
textAlign = TextAlign.Center
)
AnimatedContent(targetState = currentIndex, label = "setupShowcase") { index ->
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.height(260.dp),
shape = RoundedCornerShape(24.dp),
color = PurrfectPalette.cardOverlayColor,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Image(
painter = painterResource(slides[index].first),
contentDescription = slides[index].second,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(24.dp))
)
}
Text(
text = slides[index].second,
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
slides.forEachIndexed { index, _ ->
Box(
modifier = Modifier
.size(if (index == currentIndex) 10.dp else 8.dp)
.clip(CircleShape)
.background(if (index == currentIndex) PurrfectPalette.glowPrimary else Color.White.copy(alpha = 0.3f))
)
}
}
}
}
}

View File

@@ -74,6 +74,8 @@ import kotlinx.coroutines.withContext
import me.eternal.purrfectsnap.common.bridge.wrapper.LocaleWrapper
import me.eternal.purrfectsnap.setup.patch.AutoPatchServer
import me.eternal.purrfectsnap.setup.patch.LSPatch
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantDialog
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.screens.SetupScreen
@@ -111,6 +113,10 @@ class PatchSnapchatScreen : SetupScreen() {
var installWatcher by remember { mutableStateOf<Job?>(null) }
var downloadFinished by rememberSaveable { mutableStateOf(false) }
var showIssuesDialog by remember { mutableStateOf(false) }
val assistantRoutes = remember {
Routes(context).apply {
}
}
val logPulse by rememberInfiniteTransition(label = "logPulse").animateFloat(
initialValue = 0f,
targetValue = 1f,
@@ -313,62 +319,12 @@ class PatchSnapchatScreen : SetupScreen() {
}
if (showIssuesDialog) {
AestheticDialog(
onDismissRequest = { showIssuesDialog = false },
title = translation["setup.patch.issues_title"],
text = "",
icon = Icons.Filled.Info,
confirmButtonText = translation["setup.patch.issues_confirm"],
onConfirm = { showIssuesDialog = false },
showCloseButton = false,
customContent = {
val bodyStyle = MaterialTheme.typography.bodyMedium.copy(
color = PurrfectPalette.textSecondary,
lineHeight = 18.sp
)
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 360.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = translation["setup.patch.issues_heading"],
fontWeight = FontWeight.SemiBold,
color = Color.White,
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
Text(
text = translation["setup.patch.issues_conflict_issue"],
style = bodyStyle,
textAlign = TextAlign.Start
)
Text(
text = translation["setup.patch.issues_conflict_fix"],
style = bodyStyle,
textAlign = TextAlign.Start
)
Text(
text = translation["setup.patch.issues_adb_command"],
style = bodyStyle,
textAlign = TextAlign.Start,
softWrap = false,
modifier = Modifier.horizontalScroll(rememberScrollState())
)
Text(
text = translation["setup.patch.issues_invalid_issue"],
style = bodyStyle,
textAlign = TextAlign.Start
)
Text(
text = translation["setup.patch.issues_invalid_fix"],
style = bodyStyle,
textAlign = TextAlign.Start
)
}
}
ManagerAssistantDialog(
context = context,
routes = assistantRoutes,
initialUserMessage = "I am facing an App not installed issue or Package appears to be invalid issue while installing Snapchat. How do I fix it?",
showImprovementLogging = false,
onDismiss = { showIssuesDialog = false }
)
}

View File

@@ -431,9 +431,10 @@ class AlertDialogs(
DefaultDialogCard {
var fieldValue by remember {
mutableStateOf(property.value.get().toString().let {
val t = if (property.key.params.digitsOnlyInput) it.filter { ch -> ch.isDigit() } else it
TextFieldValue(
text = it,
selection = TextRange(it.length)
text = t,
selection = TextRange(t.length)
)
})
}
@@ -447,10 +448,21 @@ class AlertDialogs(
}
.focusRequester(focusRequester),
value = fieldValue,
onValueChange = { fieldValue = it },
keyboardOptions = when (property.key.dataType.type) {
DataProcessors.Type.INTEGER -> KeyboardOptions(keyboardType = KeyboardType.Number)
DataProcessors.Type.FLOAT -> KeyboardOptions(keyboardType = KeyboardType.Decimal)
onValueChange = { newVal ->
fieldValue = if (property.key.params.digitsOnlyInput) {
val filtered = newVal.text.filter { ch -> ch.isDigit() }
if (newVal.text != filtered) {
Toast.makeText(context, translation["manager.sections.features.digits_only_toast"], Toast.LENGTH_SHORT).show()
}
newVal.copy(text = filtered)
} else {
newVal
}
},
keyboardOptions = when {
property.key.params.digitsOnlyInput -> KeyboardOptions(keyboardType = KeyboardType.Number)
property.key.dataType.type == DataProcessors.Type.INTEGER -> KeyboardOptions(keyboardType = KeyboardType.Number)
property.key.dataType.type == DataProcessors.Type.FLOAT -> KeyboardOptions(keyboardType = KeyboardType.Decimal)
else -> KeyboardOptions(keyboardType = KeyboardType.Text)
},
singleLine = true,

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB