Sync(Feat): AI & showcase and Spoof Followers
This commit is contained in:
@@ -113,7 +113,6 @@ android {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
storeFile = File(System.getProperty("user.home"), ".android/purrfectsnap-release.keystore")
|
||||
@@ -359,6 +358,7 @@ afterEvaluate {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
properties["debug_flavor"]?.let {
|
||||
|
||||
@@ -119,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",
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
2420
app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/AI.kt
Normal file
2420
app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/AI.kt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -269,7 +269,7 @@ fun FloatingTopBar(
|
||||
if (onBack != null) {
|
||||
translationX = morphingParams.horizontalShift.toPx()
|
||||
}
|
||||
},
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
|
||||
@@ -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,10 @@ 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 ruleIdsSet by remember { derivedStateOf { currentRuleIds.toSet() } }
|
||||
|
||||
fun setRuleState(newState: RuleState?) {
|
||||
ruleState = newState
|
||||
@@ -163,12 +161,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 (!currentRuleIds.contains(friend.userId)) currentRuleIds.add(friend.userId)
|
||||
} else {
|
||||
currentRuleIds.remove(friend.userId)
|
||||
}
|
||||
@@ -176,16 +174,16 @@ class ManageRuleFeature : Routes.Route() {
|
||||
onGroupState = { group, state ->
|
||||
context.database.setRule(group.conversationId, currentRuleType.key, state)
|
||||
if (state) {
|
||||
currentRuleIds.add(group.conversationId)
|
||||
if (!currentRuleIds.contains(group.conversationId)) currentRuleIds.add(group.conversationId)
|
||||
} else {
|
||||
currentRuleIds.remove(group.conversationId)
|
||||
}
|
||||
},
|
||||
getFriendState = { friend ->
|
||||
currentRuleIds.contains(friend.userId)
|
||||
ruleIdsSet.contains(friend.userId)
|
||||
},
|
||||
getGroupState = { group ->
|
||||
currentRuleIds.contains(group.conversationId)
|
||||
ruleIdsSet.contains(group.conversationId)
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -230,59 +228,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 +292,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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,8 +59,11 @@ class SocialRootSection : Routes.Route() {
|
||||
withContext(Dispatchers.IO) {
|
||||
val dbFriends = context.database.getFriends(descOrder = true)
|
||||
val dbGroups = context.database.getGroups()
|
||||
friendList = context.sortSocialFriends(dbFriends)
|
||||
groupList = dbGroups
|
||||
val sortedFriends = context.sortSocialFriends(dbFriends)
|
||||
withContext(Dispatchers.Main) {
|
||||
friendList = sortedFriends
|
||||
groupList = dbGroups
|
||||
}
|
||||
}
|
||||
|
||||
// Real-time synchronization from the bridge
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -645,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,8 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.eternal.purrfectsnap.LogLine
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantEntry
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantTriggerStyle
|
||||
import me.eternal.purrfectsnap.LogReader
|
||||
import me.eternal.purrfectsnap.R
|
||||
import me.eternal.purrfectsnap.action.EnumQuickActions
|
||||
@@ -154,24 +156,28 @@ object LegacyTheme : ThemeContract {
|
||||
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 {
|
||||
Text(text = it, color = Color.White, fontSize = 13.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(text = it, color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,8 +185,22 @@ object LegacyTheme : ThemeContract {
|
||||
|
||||
@Composable
|
||||
fun RowScope.LocalHomeActionChips() {
|
||||
LocalTopBarActionChip(icon = Icons.Filled.BugReport, label = context.translation["manager.routes.home_logs"]) { routes.homeLogs.navigate() }
|
||||
LocalTopBarActionChip(icon = Icons.Filled.Info, label = translation["manager.routes.home_about"]) { routes.about.navigate() }
|
||||
ManagerAssistantEntry(
|
||||
context = context,
|
||||
routes = routes,
|
||||
style = ManagerAssistantTriggerStyle.DEFAULT,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
LocalTopBarActionChip(
|
||||
icon = Icons.Filled.BugReport,
|
||||
label = context.translation["manager.routes.home_logs"],
|
||||
modifier = Modifier.weight(1f)
|
||||
) { routes.homeLogs.navigate() }
|
||||
LocalTopBarActionChip(
|
||||
icon = Icons.Filled.Info,
|
||||
label = translation["manager.routes.home_about"],
|
||||
modifier = Modifier.weight(1f)
|
||||
) { routes.about.navigate() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -476,18 +496,26 @@ object LegacyTheme : ThemeContract {
|
||||
Column(modifier = Modifier.fillMaxSize().verticalScroll(scrollState).padding(bottom = contentBottomPadding)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(WindowInsets.statusBars.asPaddingValues()).padding(horizontal = cardMargin, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
LocalTopBarActionChip(icon = Icons.Filled.Notifications, label = null, contentDescription = translation["announcements_button_description"]) {
|
||||
showAnnouncementsDialog = true; loadAnnouncements()
|
||||
}
|
||||
LocalTopBarActionChip(icon = Icons.Filled.Description, label = null, contentDescription = translation.getOrNull("changelog_button_description") ?: "Open full changelog") {
|
||||
showFullChangelogDialog = true; loadFullChangelog(changelogUrl)
|
||||
}
|
||||
}
|
||||
Row(modifier = Modifier.wrapContentWidth(Alignment.End), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
LocalTopBarActionChip(
|
||||
icon = Icons.Filled.Notifications,
|
||||
label = null,
|
||||
modifier = Modifier.width(56.dp),
|
||||
contentDescription = translation["announcements_button_description"]
|
||||
) { showAnnouncementsDialog = true; loadAnnouncements() }
|
||||
LocalTopBarActionChip(
|
||||
icon = Icons.Filled.Description,
|
||||
label = null,
|
||||
modifier = Modifier.width(56.dp),
|
||||
contentDescription = translation.getOrNull("changelog_button_description") ?: "Open full changelog"
|
||||
) { showFullChangelogDialog = true; loadFullChangelog(changelogUrl) }
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
LocalHomeActionChips()
|
||||
}
|
||||
}
|
||||
@@ -1705,9 +1733,15 @@ object LegacyTheme : ThemeContract {
|
||||
)
|
||||
Text(text = translation["about_tagline"] ?: "", fontSize = 13.sp, color = Color(0xFFD9D3FF), textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth())
|
||||
Text(text = translation["about_lead_developers_title"] ?: "Lead Developers", fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = Color.White, modifier = Modifier.padding(top = 10.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically) {
|
||||
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))
|
||||
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1769,11 +1803,14 @@ object LegacyTheme : ThemeContract {
|
||||
name: String,
|
||||
imageRes: Int,
|
||||
avenirNext: FontFamily,
|
||||
subtitle: String? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val tapSource = remember { MutableInteractionSource() }
|
||||
Surface(
|
||||
modifier = modifier.scaleOnPress(tapSource),
|
||||
modifier = modifier
|
||||
.height(150.dp)
|
||||
.scaleOnPress(tapSource),
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
color = Color.White.copy(alpha = 0.06f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)),
|
||||
@@ -1781,9 +1818,11 @@ object LegacyTheme : ThemeContract {
|
||||
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),
|
||||
@@ -1798,15 +1837,28 @@ object LegacyTheme : ThemeContract {
|
||||
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 = 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 = Color(0xFFD9D3FF),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
BIN
app/src/main/res/drawable/pfp_kaladin.jpg
Normal file
BIN
app/src/main/res/drawable/pfp_kaladin.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
BIN
app/src/main/res/drawable/pfp_schrodingerspet.jpg
Normal file
BIN
app/src/main/res/drawable/pfp_schrodingerspet.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
BIN
app/src/main/res/drawable/setup_slide_downloads.jpg
Normal file
BIN
app/src/main/res/drawable/setup_slide_downloads.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
BIN
app/src/main/res/drawable/setup_slide_plus.jpg
Normal file
BIN
app/src/main/res/drawable/setup_slide_plus.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
BIN
app/src/main/res/drawable/setup_slide_upload_tag.jpg
Normal file
BIN
app/src/main/res/drawable/setup_slide_upload_tag.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 354 KiB |
File diff suppressed because it is too large
Load Diff
@@ -197,7 +197,7 @@
|
||||
},
|
||||
"sections": {
|
||||
"home": {
|
||||
"version_title": "v{versionName} \u00b7 by ΞTΞRNAL",
|
||||
"version_title": "v{versionName} \u00b7 by \u039eT\u039eRNAL",
|
||||
"update_title": "PurrfectSnap Update",
|
||||
"update_content": "Version {version} is available!",
|
||||
"update_button": "Download",
|
||||
@@ -247,9 +247,9 @@
|
||||
"about_tagline": "An Xposed Module meant to enhance your Snapchat experience!",
|
||||
"about_lead_developers_title": "Lead Developers",
|
||||
"about_story_title": "Our Story",
|
||||
"about_story": "PurrfectSnap was founded on 2nd of October, 2025, as a fork of SnapEnhance by ΞTΞRNAL with a vision to provide users the quality Snapchat experience they deserve. This app was just meant to be a minor update in the SnapEnhance repository, but it soon became a separate app wherein the contributors kept adding features. Then the developer <RSR/> joined the team, and this app soon became a huge success.\n\nWe would also like to convey our huge thanks to rhunk, the lead developer of SnapEnhance, as without him, this app wouldn't even exist. We are immensely grateful to him.\n\nWe received much love and support and gained 1K+ downloads in just two days! We thank all users and contributors; without your support, we wouldn't have reached this place.\n\nLastly, we would like to thank all our admins, notably: CLASSIC GENIUS, Harry, Sujal, Zain & schrodingerspet, who were right there with us from the very beginning. We would also like to thank all testers, notably Leo & Toxic, who tested and reported bugs continuously. We are immensely grateful for your contribution.",
|
||||
"about_story": "PurrfectSnap was founded on 2nd of October, 2025, as a fork of SnapEnhance by \u039eT\u039eRNAL with a vision to provide users the quality Snapchat experience they deserve. This app was just meant to be a minor update in the SnapEnhance repository, but it soon became a separate app wherein the contributors kept adding features. Then the developer <RSR/> joined the team, and this app soon became a huge success.\n\nWe would also like to convey our huge thanks to rhunk, the lead developer of SnapEnhance, as without him, this app wouldn't even exist. We are immensely grateful to him.\n\nWe received much love and support and gained 1K+ downloads in just two days! We thank all users and contributors; without your support, we wouldn't have reached this place.\n\nLastly, we would like to thank all our admins, notably: CLASSIC GENIUS, Harry, Sujal, Zain & schrodingerspet, who were right there with us from the very beginning. We would also like to thank all testers, notably Leo & Toxic, who tested and reported bugs continuously. We are immensely grateful for your contribution.",
|
||||
"about_thanks_title": "With love, PurrfectSnap Team",
|
||||
"about_magic_toast": "Tap 5 times in this screen to see some magic 😉!",
|
||||
"about_magic_toast": "Tap 5 times in this screen to see some magic \ud83d\ude09!",
|
||||
"github_button": "GitHub",
|
||||
"telegram_button": "Telegram"
|
||||
},
|
||||
@@ -377,21 +377,22 @@
|
||||
"remove_all_tasks_confirm": "Remove all tasks?"
|
||||
},
|
||||
"features": {
|
||||
"disabled": "Disabled",
|
||||
"export_option": "Export",
|
||||
"import_option": "Import",
|
||||
"reset_option": "Reset",
|
||||
"config_export_success_toast": "Config exported successfully",
|
||||
"config_import_success_toast": "Config imported successfully",
|
||||
"config_import_failure_toast": "Failed to import config {error}",
|
||||
"config_export_failure_toast": "Failed to export config {error}",
|
||||
"saved_config_snackbar": "Config saved",
|
||||
"older_required": "This feature requires Snapchat v{version} or older to work correctly",
|
||||
"newer_required": "This feature requires Snapchat v{version} or newer to work correctly",
|
||||
"disabled": "Disabled",
|
||||
"export_option": "Export",
|
||||
"import_option": "Import",
|
||||
"reset_option": "Reset",
|
||||
"config_export_success_toast": "Config exported successfully",
|
||||
"config_import_success_toast": "Config imported successfully",
|
||||
"config_import_failure_toast": "Failed to import config {error}",
|
||||
"config_export_failure_toast": "Failed to export config {error}",
|
||||
"saved_config_snackbar": "Config saved",
|
||||
"older_required": "This feature requires Snapchat v{version} or older to work correctly",
|
||||
"newer_required": "This feature requires Snapchat v{version} or newer to work correctly",
|
||||
"search_button": "Search",
|
||||
"search_results_count": "{count} messages",
|
||||
"clear_history": "Clear search history",
|
||||
"subtitle": "Explore and manage premium features"
|
||||
"subtitle": "Explore and manage premium features",
|
||||
"digits_only_toast": "Only numbers are allowed."
|
||||
},
|
||||
"bypass_status": {
|
||||
"active": "PurrAura Active",
|
||||
@@ -1277,6 +1278,16 @@
|
||||
"description": "The custom Snap Score you want to display (max 9,999,999)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"spoof_followers_count": {
|
||||
"name": "Spoof Followers Count",
|
||||
"description": "Spoof your follower count on your profile (local only).",
|
||||
"properties": {
|
||||
"custom_followers_count": {
|
||||
"name": "Custom Followers Count",
|
||||
"description": "Number to show (digits only)."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1534,7 +1545,10 @@
|
||||
"name": "Bypass Message Action Restrictions",
|
||||
"description": "Allows you to react to a snap without having opened it or to save an unsaveable message"
|
||||
},
|
||||
"pre_fetch_snaps": { "name": "Snap Pre-Fetch", "description": "Fetches snap media into Snapchat's internal cache once the snap arrives. Note: Turning it on increases background data usage." },
|
||||
"pre_fetch_snaps": {
|
||||
"name": "Snap Pre-Fetch",
|
||||
"description": "Fetches snap media into Snapchat's internal cache once the snap arrives. Note: Turning it on increases background data usage."
|
||||
},
|
||||
"remove_groups_locked_status": {
|
||||
"name": "Remove Groups Locked Status",
|
||||
"description": "Allows you to view group information after being kicked"
|
||||
@@ -1755,9 +1769,12 @@
|
||||
},
|
||||
"thermal_protection": {
|
||||
"name": "Thermal Protection",
|
||||
"description": "Automatically throttles the engine and increases delays if the device temperature exceeds 40°C to prevent overheating"
|
||||
"description": "Automatically throttles the engine and increases delays if the device temperature exceeds 40\u00b0C to prevent overheating"
|
||||
},
|
||||
"only_on_wifi": {
|
||||
"name": "Auto Open only on Wi-Fi",
|
||||
"description": "Only process queue when connected to a Wi-Fi network to save mobile data"
|
||||
},
|
||||
"only_on_wifi": { "name": "Auto Open only on Wi-Fi", "description": "Only process queue when connected to a Wi-Fi network to save mobile data" },
|
||||
"content_type_snap": "Snap",
|
||||
"only_when_idle": {
|
||||
"name": "Auto Open Schedule",
|
||||
@@ -1767,41 +1784,9 @@
|
||||
"name": "Auto Open Scheduler",
|
||||
"description": "Define the start and end times for scheduled throttled processing."
|
||||
},
|
||||
"safe_processing": { "name": "Auto Open with stealth pace", "description": "Adds variable delays and natural breaks to remain undetected. Turn off for maximum speed." }
|
||||
}
|
||||
},
|
||||
"pre_fetch_snaps": { "name": "Snap Pre-Fetch", "description": "Fetches snap media into Snapchat's internal cache once the snap arrives. Note: Turning it on increases background data usage." },
|
||||
"instant_translation": {
|
||||
"name": "Message Translator",
|
||||
"description": "Configure the message translator"
|
||||
},
|
||||
"auto_delete_sent_messages": {
|
||||
"name": "Auto Delete Sent Messages",
|
||||
"description": "Automatically deletes sent messages after a specified time period",
|
||||
"properties": {
|
||||
"allow_running_in_background": {
|
||||
"name": "Allow Running in Background",
|
||||
"description": "Allows Auto Delete Sent Messages to run in the background. Note: This will significantly drain your battery"
|
||||
},
|
||||
"delete_after_value": {
|
||||
"name": "Delete After (value)",
|
||||
"description": "Time value before deleting the sent message"
|
||||
},
|
||||
"delete_after_unit": {
|
||||
"name": "Time Unit",
|
||||
"description": "Select the time unit for deletion delay"
|
||||
},
|
||||
"message_types": {
|
||||
"name": "Message Types",
|
||||
"description": "Select which message types should be auto-deleted"
|
||||
},
|
||||
"show_countdown": {
|
||||
"name": "Show Countdown",
|
||||
"description": "Show countdown before deleting the message"
|
||||
},
|
||||
"show_notification": {
|
||||
"name": "Show Notification",
|
||||
"description": "Show notification during countdown"
|
||||
"safe_processing": {
|
||||
"name": "Auto Open with stealth pace",
|
||||
"description": "Adds variable delays and natural breaks to remain undetected. Turn off for maximum speed."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1859,6 +1844,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"auto_delete_sent_messages": {
|
||||
"name": "Auto Delete Sent Messages",
|
||||
"description": "Automatically deletes sent messages after a specified time period",
|
||||
"properties": {
|
||||
"allow_running_in_background": {
|
||||
"name": "Allow Running in Background",
|
||||
"description": "Allows Auto Delete Sent Messages to run in the background. Note: This will significantly drain your battery"
|
||||
},
|
||||
"delete_after_value": {
|
||||
"name": "Delete After (value)",
|
||||
"description": "Time value before deleting the sent message"
|
||||
},
|
||||
"delete_after_unit": {
|
||||
"name": "Time Unit",
|
||||
"description": "Select the time unit for deletion delay"
|
||||
},
|
||||
"message_types": {
|
||||
"name": "Message Types",
|
||||
"description": "Select which message types should be auto-deleted"
|
||||
},
|
||||
"show_countdown": {
|
||||
"name": "Show Countdown",
|
||||
"description": "Show countdown before deleting the message"
|
||||
},
|
||||
"show_notification": {
|
||||
"name": "Show Notification",
|
||||
"description": "Show notification during countdown"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scheduled_send_allow_running_in_background": {
|
||||
"name": "Allow Scheduled Send to Run in Background",
|
||||
"description": "Keep scheduled messages processing while Snapchat is in the background"
|
||||
@@ -2144,7 +2159,15 @@
|
||||
"name": "HEVC Recording",
|
||||
"description": "Uses HEVC (H.265) codec for video recording"
|
||||
},
|
||||
"camera_tweaks": { "name": "Upgraded Camera Engine", "description": "Enables professional hardware ISP processing modes for better dynamic range" }, "audio_video": { "name": "Upgraded Audio and Video", "description": "Increases Video bitrate to 30Mbps and Audio to 320kbps/48kHz" }, "video_record_timer": {
|
||||
"camera_tweaks": {
|
||||
"name": "Upgraded Camera Engine",
|
||||
"description": "Enables professional hardware ISP processing modes for better dynamic range"
|
||||
},
|
||||
"audio_video": {
|
||||
"name": "Upgraded Audio and Video",
|
||||
"description": "Increases Video bitrate to 30Mbps and Audio to 320kbps/48kHz"
|
||||
},
|
||||
"video_record_timer": {
|
||||
"name": "Video Recording Timer",
|
||||
"description": "Shows a recording timer overlay when recording video"
|
||||
},
|
||||
@@ -2714,7 +2737,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"network_optimization": { "name": "Improved Network Connectivity", "description": "Optimizes network socket buffers for maximum stability and high-speed upload/download performance" }, "better_transcript": {
|
||||
"network_optimization": {
|
||||
"name": "Improved Network Connectivity",
|
||||
"description": "Optimizes network socket buffers for maximum stability and high-speed upload/download performance"
|
||||
},
|
||||
"better_transcript": {
|
||||
"name": "Better Transcript",
|
||||
"description": "Improves the voice note transcript",
|
||||
"properties": {
|
||||
@@ -3843,7 +3870,12 @@
|
||||
"export_failed_toast": "Failed to export account. Check logs for more info.",
|
||||
"forced_logout_toast": "Removed account due to forced logout"
|
||||
},
|
||||
"auto_open_snaps": { "title": "Auto Open Snaps", "processed_count": "Opened", "queue_size": "Queue", "action_reset": "Reset Statistics", "priority_title": "Auto Open Snaps (Priority)",
|
||||
"auto_open_snaps": {
|
||||
"title": "Auto Open Snaps",
|
||||
"processed_count": "Opened",
|
||||
"queue_size": "Queue",
|
||||
"action_reset": "Reset Count",
|
||||
"priority_title": "Auto Open Snaps (Priority)",
|
||||
"auto_open_schedule": {
|
||||
"title": "Auto Open Scheduler",
|
||||
"start": "Start",
|
||||
@@ -3860,7 +3892,6 @@
|
||||
"action_pause": "Pause",
|
||||
"action_resume": "Resume",
|
||||
"action_clear": "Clear Queue",
|
||||
"action_reset": "Reset Count",
|
||||
"error_content": "Failed to open snap from {sender}: {error}",
|
||||
"resumed_feedback": "Auto Open Resumed",
|
||||
"paused_feedback": "Auto Open Paused",
|
||||
@@ -3877,9 +3908,9 @@
|
||||
"speed_throttled": "Throttled",
|
||||
"estimated_time": "Estimated Time",
|
||||
"notification_statistics": "STATISTICS",
|
||||
"notification_total_opened": "Lifetime Opened",
|
||||
"notification_total_opened": "Total Snaps Opened",
|
||||
"notification_queue_preview": "QUEUE PREVIEW",
|
||||
"notification_no_snaps_queue": "Monitoring snaps in background...",
|
||||
"notification_no_snaps_queue": "No snaps in queue.",
|
||||
"queue_cleared": "Queue cleared and statistics reset",
|
||||
"queue_cleared_title": "Queue cleared",
|
||||
"queue_cleared_reset": "Queue Cleared & Reset",
|
||||
@@ -3894,12 +3925,8 @@
|
||||
"conversation_type_group_chat": "Group Chat",
|
||||
"conversation_type_chat": "Chat",
|
||||
"notification_status": "Status",
|
||||
"notification_statistics": "STATISTICS",
|
||||
"notification_queue_size": "Queue Size",
|
||||
"notification_total_opened": "Total Snaps Opened",
|
||||
"notification_queue_preview": "QUEUE PREVIEW",
|
||||
"notification_processing_continue": "Processing will continue automatically...",
|
||||
"notification_no_snaps_queue": "No snaps in queue.",
|
||||
"notification_queue_cleared_opened": "Queue cleared ({opened} opened)",
|
||||
"content_type_photo_video_snap": "Photo/Video Snap",
|
||||
"conversation_type_group_with_name": "Group: {name}",
|
||||
@@ -4027,7 +4054,7 @@
|
||||
"username": "Username",
|
||||
"user_id": "User ID",
|
||||
"posted_on": "Posted",
|
||||
"loading_username": "Loading…",
|
||||
"loading_username": "Loading\u2026",
|
||||
"username_copied": "Username copied",
|
||||
"user_id_copied": "User ID copied",
|
||||
"friend_status": "Friend status",
|
||||
@@ -4112,10 +4139,10 @@
|
||||
"search": {
|
||||
"placeholder": "Search"
|
||||
},
|
||||
"filters": {
|
||||
"newest_first": "Newest first",
|
||||
"pick_a_date": "Pick a date",
|
||||
"title": "Filters",
|
||||
"filters": {
|
||||
"newest_first": "Newest first",
|
||||
"pick_a_date": "Pick a date",
|
||||
"title": "Filters",
|
||||
"search_by": "Search by",
|
||||
"since": "Since",
|
||||
"until": "Until",
|
||||
@@ -4322,8 +4349,6 @@
|
||||
"added": "Added",
|
||||
"no_friends_found": "No friends found",
|
||||
"no_messages": "No messages",
|
||||
"message": "Message",
|
||||
"type_message": "Type message...",
|
||||
"exporting_memories": "Exporting memories... ({failed} failed)"
|
||||
},
|
||||
"clear_friend_feed": "Clear Friend Feed",
|
||||
|
||||
@@ -81,6 +81,7 @@ class ConfigParams(
|
||||
var inputCheck: ((String) -> Boolean)? = { true },
|
||||
var filenameFilter: ((String) -> Boolean)? = null,
|
||||
var versionCheck: VersionCheck? = null,
|
||||
var digitsOnlyInput: Boolean = false,
|
||||
) {
|
||||
val notices get() = _notices?.let { FeatureNotice.entries.filter { flag -> it and flag.id != 0 } } ?: emptyList()
|
||||
val flags get() = _flags?.let { ConfigFlag.entries.filter { flag -> it and flag.id != 0 } } ?: emptyList()
|
||||
|
||||
@@ -77,4 +77,14 @@ class UserInterfaceTweaks : ConfigContainer() {
|
||||
}
|
||||
|
||||
val spoofSnapScore = container("spoof_snap_score", SpoofSnapScore()) { requireRestart() }
|
||||
|
||||
inner class SpoofFollowersCount : ConfigContainer(hasGlobalState = true) {
|
||||
val customFollowersCount = string("custom_followers_count") {
|
||||
requireRestart()
|
||||
digitsOnlyInput = true
|
||||
inputCheck = { input -> input.isEmpty() || input.all { it.isDigit() } }
|
||||
}
|
||||
}
|
||||
|
||||
val spoofFollowersCount = container("spoof_followers_count", SpoofFollowersCount()) { requireRestart() }
|
||||
}
|
||||
|
||||
@@ -300,6 +300,8 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
this@AutoOpenSnaps.context.event.subscribe(BuildMessageEvent::class, priority = 103) { event ->
|
||||
if (autoOpenConfig.globalState == false || !engineActive.get()) return@subscribe
|
||||
val message = event.message
|
||||
|
||||
// 1. Basic Filters & Self-Check
|
||||
if (message.messageState != MessageState.COMMITTED || message.senderId?.toString() == this@AutoOpenSnaps.context.database.myUserId) return@subscribe
|
||||
|
||||
val clientMessageId = message.messageDescriptor?.messageId ?: return@subscribe
|
||||
@@ -310,8 +312,20 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
if (contentType != ContentType.SNAP && contentType != ContentType.EXTERNAL_MEDIA) return@subscribe
|
||||
if (!canUseRule(conversationId)) return@subscribe
|
||||
|
||||
// Prevent re-queueing the same message while it is currently being processed
|
||||
// 2. Memory Gating: Prevent processing the same session snap multiple times
|
||||
if (openedSnapsIds.contains(clientMessageId)) return@subscribe
|
||||
|
||||
// 3. Database Authority: Immediate check to see if snap is already opened
|
||||
val dbMessage = this@AutoOpenSnaps.context.database.getConversationMessageFromId(clientMessageId)
|
||||
if (dbMessage?.isViewedByUser == 1) return@subscribe
|
||||
|
||||
// 4. Temporal Gating: Ignore ancient unread snaps (fixes 'Ghost Storm' during sync)
|
||||
val now = System.currentTimeMillis()
|
||||
val messageTime = message.messageMetadata?.createdAt ?: 0L
|
||||
if (now - messageTime > 28_800_000L) { // 8-hour window
|
||||
return@subscribe
|
||||
}
|
||||
|
||||
openedSnapsIds.add(clientMessageId)
|
||||
|
||||
val senderId = message.senderId?.toString() ?: "unknown"
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package me.eternal.purrfectsnap.core.features.impl.ui
|
||||
|
||||
import android.widget.TextView
|
||||
import me.eternal.purrfectsnap.core.features.Feature
|
||||
import me.eternal.purrfectsnap.core.util.hook.HookStage
|
||||
import me.eternal.purrfectsnap.core.util.hook.hook
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
import java.math.MathContext
|
||||
import java.math.RoundingMode
|
||||
import java.text.NumberFormat
|
||||
import java.util.Locale
|
||||
|
||||
class FakeFollowersCount : Feature("Fake Followers Count") {
|
||||
|
||||
override fun init() {
|
||||
if (context.config.userInterface.spoofFollowersCount.globalState != true) return
|
||||
val raw = context.config.userInterface.spoofFollowersCount.customFollowersCount.getNullable()?.trim()?.takeIf { it.isNotBlank() }
|
||||
?: return
|
||||
val digits = raw.replace(Regex("[^0-9]"), "")
|
||||
if (digits.isEmpty()) return
|
||||
val followerValue = try {
|
||||
BigInteger(digits)
|
||||
} catch (_: NumberFormatException) {
|
||||
return
|
||||
}
|
||||
|
||||
onNextActivityCreate {
|
||||
TextView::class.java.hook("setText", HookStage.BEFORE) { param ->
|
||||
val textView = param.thisObject() as? TextView ?: return@hook
|
||||
if (textView.javaClass.name !in COMPOSER_SNAP_TEXT_VIEW) return@hook
|
||||
val arg0 = param.argNullable<Any>(0) ?: return@hook
|
||||
if (arg0 !is CharSequence) return@hook
|
||||
val text = arg0.toString()
|
||||
if (!FOLLOWERS_LINE.containsMatchIn(text)) return@hook
|
||||
val display = formatFollowersDisplay(followerValue)
|
||||
val replaced = FOLLOWERS_LINE.replace(text) { mr ->
|
||||
"$display ${mr.groupValues[2]}"
|
||||
}
|
||||
param.setArg(0, replaced)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatFollowersDisplay(n: BigInteger): String {
|
||||
val v = n.max(BigInteger.ZERO)
|
||||
if (v < TEN_THOUSAND) {
|
||||
return NumberFormat.getIntegerInstance(Locale.US).format(v.toLong())
|
||||
}
|
||||
val (scaled, suffix) = scaleToTier(v)
|
||||
return formatMantissaMaxFourDigits(scaled) + suffix
|
||||
}
|
||||
|
||||
private fun scaleToTier(v: BigInteger): Pair<BigDecimal, String> {
|
||||
var i = SCALE_TIERS.indexOfLast { v >= it.floor }.coerceAtLeast(0)
|
||||
var floor = SCALE_TIERS[i].floor
|
||||
var suffix = SCALE_TIERS[i].suffix
|
||||
var scaled = BigDecimal(v, MATH_CTX).divide(BigDecimal(floor, MATH_CTX), MATH_CTX)
|
||||
while (scaled >= THOUSAND_BD && i + 1 < SCALE_TIERS.size) {
|
||||
i++
|
||||
floor = SCALE_TIERS[i].floor
|
||||
suffix = SCALE_TIERS[i].suffix
|
||||
scaled = BigDecimal(v, MATH_CTX).divide(BigDecimal(floor, MATH_CTX), MATH_CTX)
|
||||
}
|
||||
return Pair(scaled, suffix)
|
||||
}
|
||||
|
||||
private fun formatMantissaMaxFourDigits(scaled: BigDecimal): String {
|
||||
val x = scaled.abs().setScale(12, RoundingMode.HALF_UP)
|
||||
if (x.compareTo(BigDecimal.ZERO) == 0) return "0"
|
||||
if (x >= HUNDRED) {
|
||||
val i = x.setScale(0, RoundingMode.HALF_UP)
|
||||
var s = i.toPlainString()
|
||||
if (digitCount(s) > 4) {
|
||||
s = x.round(MathContext(4, RoundingMode.HALF_UP)).setScale(0, RoundingMode.HALF_UP).toPlainString()
|
||||
}
|
||||
return s
|
||||
}
|
||||
if (x >= TEN) {
|
||||
var s = stripFrac(x.setScale(1, RoundingMode.HALF_UP))
|
||||
if (digitCount(s) > 4) {
|
||||
s = x.setScale(0, RoundingMode.HALF_UP).toPlainString()
|
||||
}
|
||||
return s
|
||||
}
|
||||
var s = stripFrac(x.setScale(2, RoundingMode.HALF_UP))
|
||||
if (digitCount(s) > 4) {
|
||||
s = stripFrac(x.setScale(1, RoundingMode.HALF_UP))
|
||||
}
|
||||
if (digitCount(s) > 4) {
|
||||
s = x.setScale(0, RoundingMode.HALF_UP).toPlainString()
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
private fun digitCount(s: String) = s.count { it.isDigit() }
|
||||
|
||||
private fun stripFrac(d: BigDecimal): String {
|
||||
var s = d.stripTrailingZeros().toPlainString()
|
||||
if ('.' in s) {
|
||||
s = s.trimEnd('0').trimEnd('.')
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
private data class ScaleTier(val floor: BigInteger, val suffix: String)
|
||||
|
||||
companion object {
|
||||
private val FOLLOWERS_LINE = Regex("^([\\d,]+)\\s+(Followers)\\b", RegexOption.IGNORE_CASE)
|
||||
private val COMPOSER_SNAP_TEXT_VIEW = setOf(
|
||||
"com.snap.valdi.views.ComposerSnapTextView",
|
||||
"com.snap.composer.views.ComposerSnapTextView",
|
||||
)
|
||||
private val MATH_CTX = MathContext(24, RoundingMode.HALF_UP)
|
||||
private val TEN_THOUSAND = BigInteger("10000")
|
||||
private val THOUSAND_BD = BigDecimal("1000")
|
||||
private val HUNDRED = BigDecimal("100")
|
||||
private val TEN = BigDecimal("10")
|
||||
private val SCALE_TIERS = listOf(
|
||||
ScaleTier(BigInteger("1000"), "K"),
|
||||
ScaleTier(BigInteger("1000000"), "M"),
|
||||
ScaleTier(BigInteger("1000000000"), "B"),
|
||||
ScaleTier(BigInteger("1000000000000"), "T"),
|
||||
ScaleTier(BigInteger("1000000000000000"), "Q"),
|
||||
ScaleTier(BigInteger("1000000000000000000"), "E"),
|
||||
ScaleTier(BigInteger("1000000000000000000000"), "Z"),
|
||||
ScaleTier(BigInteger("1000000000000000000000000"), "Y"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import me.eternal.purrfectsnap.core.util.ktx.getObjectField
|
||||
import me.eternal.purrfectsnap.core.wrapper.impl.SnapUUID
|
||||
import me.eternal.purrfectsnap.mapper.impl.CallbackMapper
|
||||
import java.util.ArrayList
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType = MessagingRuleType.HIDE_FRIEND_FEED) {
|
||||
@Volatile
|
||||
@@ -21,6 +22,10 @@ class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType
|
||||
@Volatile
|
||||
private var cachedRuleIdsAt = 0L
|
||||
|
||||
private val conversationTargetsCache = ConcurrentHashMap<String, Set<String>>()
|
||||
private val hideDecisionCache = ConcurrentHashMap<String, Boolean>()
|
||||
private var lastRuleIdsHash = 0
|
||||
|
||||
private fun createDeletedFeedEntry(conversationIdInstance: Any) = findClass("com.snapchat.client.messaging.DeletedFeedEntry").dataBuilder {
|
||||
from("mFeedEntryIdentifier") {
|
||||
set("mConversationId", conversationIdInstance)
|
||||
@@ -39,13 +44,15 @@ class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType
|
||||
}
|
||||
|
||||
private fun resolveRuleTargets(conversationId: String): Set<String> {
|
||||
val targets = linkedSetOf(conversationId)
|
||||
context.database.getDMOtherParticipant(conversationId)?.let { targets.add(it) }
|
||||
context.database.getFeedEntryByConversationId(conversationId)?.let { entry ->
|
||||
entry.friendUserId?.let { targets.add(it) }
|
||||
entry.participants?.forEach { targets.add(it) }
|
||||
return conversationTargetsCache.getOrPut(conversationId) {
|
||||
val targets = linkedSetOf(conversationId)
|
||||
context.database.getDMOtherParticipant(conversationId)?.let { targets.add(it) }
|
||||
context.database.getFeedEntryByConversationId(conversationId)?.let { entry ->
|
||||
entry.friendUserId?.let { targets.add(it) }
|
||||
entry.participants?.forEach { targets.add(it) }
|
||||
}
|
||||
targets
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
private fun shouldHideConversation(
|
||||
@@ -54,8 +61,18 @@ class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType
|
||||
ruleState: RuleState?
|
||||
): Boolean {
|
||||
if (ruleState == null) return false
|
||||
val isExplicitRuleMatch = resolveRuleTargets(conversationId).any { it in ruleIds }
|
||||
return if (ruleState == RuleState.BLACKLIST) !isExplicitRuleMatch else isExplicitRuleMatch
|
||||
|
||||
// Industrial Cache Gating: Clear decisions if the master rule list changed
|
||||
val currentHash = ruleIds.hashCode()
|
||||
if (currentHash != lastRuleIdsHash) {
|
||||
hideDecisionCache.clear()
|
||||
lastRuleIdsHash = currentHash
|
||||
}
|
||||
|
||||
return hideDecisionCache.getOrPut(conversationId) {
|
||||
val isExplicitRuleMatch = resolveRuleTargets(conversationId).any { it in ruleIds }
|
||||
if (ruleState == RuleState.BLACKLIST) !isExplicitRuleMatch else isExplicitRuleMatch
|
||||
}
|
||||
}
|
||||
|
||||
private fun filterFriendFeed(
|
||||
|
||||
Reference in New Issue
Block a user