Standardized sub-page layouts and enhanced system stability across all modules

This commit is contained in:
DarkKnight2122
2026-02-23 21:46:42 +05:30
parent 7623ec30c9
commit f8e5d7370a
35 changed files with 2566 additions and 3521 deletions

View File

@@ -132,7 +132,7 @@ 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"],
@@ -140,11 +140,20 @@ class MainActivity : ComponentActivity() {
showCloseButton = false,
opaque = true,
customContent = {
Text(
text = managerContext.translation["wrong_apk_message"],
color = PurrfectPalette.textSecondary,
lineHeight = 18.sp
)
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = managerContext.translation["setup.activity.wrong_apk_message"],
color = PurrfectPalette.textSecondary,
lineHeight = 20.sp,
fontSize = 15.sp,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
modifier = Modifier.padding(horizontal = 8.dp)
)
}
}
)
}
@@ -163,7 +172,7 @@ class MainActivity : ComponentActivity() {
insetsController.isAppearanceLightNavigationBars = isLight
}
// Floating bottom bar height and vertical spacing so floating action buttons and scrolling content remain readable:
val bottomPadding = 80.dp + 16.dp +
val bottomPadding = 82.dp + 4.dp +
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
routes.bottomPadding = bottomPadding
val navBackStackEntry by navController.currentBackStackEntryAsState()

View File

@@ -33,6 +33,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
@@ -72,6 +73,7 @@ import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -122,22 +124,36 @@ class Navigation(
) {
private val translation by lazy { context.translation.getCategory("manager.navigation") }
var openBottomBarCustomization by mutableStateOf(false)
var globalScrollOffset by mutableIntStateOf(0)
@Composable
fun TopBar() {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = remember(navBackStackEntry) { routes.getCurrentRoute(navBackStackEntry) }
if (currentRoute?.routeInfo?.hasOwnTopBar == true) return
val shrinkThreshold = 300f
val focusFactor = (globalScrollOffset / shrinkThreshold).coerceIn(0f, 1f)
val headerHeight = lerp(64.dp, 48.dp, focusFactor)
val canGoBack = remember(navBackStackEntry) {
currentRoute?.let { !it.routeInfo.primary || it.routeInfo.childIds.contains(routes.currentDestination) } == true
}
TopAppBar(
modifier = Modifier.height(headerHeight),
title = {
currentRoute?.apply {
title?.invoke() ?: routeInfo.translatedKey?.value?.let {
Text(
text = it,
maxLines = 1,
overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis,
modifier = Modifier.graphicsLayer {
// Title stays visible but scales slightly down
scaleX = 1f - (focusFactor * 0.05f)
scaleY = 1f - (focusFactor * 0.05f)
translationY = (-2 * focusFactor).dp.toPx()
}
)
}
}
@@ -146,7 +162,11 @@ class Navigation(
val backButtonAnimation by animateFloatAsState(if (canGoBack) 1f else 0f, label = "backButton")
Box(
modifier = Modifier
.graphicsLayer { alpha = backButtonAnimation }
.graphicsLayer {
alpha = backButtonAnimation
scaleX = 1f - (focusFactor * 0.1f)
scaleY = 1f - (focusFactor * 0.1f)
}
.width(lerp(0.dp, 48.dp, backButtonAnimation))
.height(48.dp)
) {
@@ -177,6 +197,13 @@ class Navigation(
listOf(routes.tasks, routes.features, routes.home, routes.social, routes.scripting, routes.friendTracker)
}
val availableRouteMap = remember(availableRoutes) { availableRoutes.associateBy { it.routeInfo.id } }
val shrinkThreshold = 300f
val focusFactor = (globalScrollOffset / shrinkThreshold).coerceIn(0f, 1f)
val barHeight = lerp(82.dp, 64.dp, focusFactor)
val labelAlpha = (1f - (focusFactor * 2.5f)).coerceIn(0f, 1f)
val iconTranslationY = (10 * focusFactor).dp // Sinks towards vertical center
val prefs = remember { context.sharedPreferences }
val defaultOrder = remember { listOf("tasks", "features", "home", "social", "scripts") }
fun loadSelected(): List<String> {
@@ -233,11 +260,20 @@ class Navigation(
val animatedBarWidth by animateDpAsState(targetValue = targetBarWidth ?: 0.dp, label = "barWidth")
Surface(
shape = barShape,
color = Color.Transparent,
color = Color.White.copy(alpha = 0.08f), // Restored frosted glass harmony
contentColor = MaterialTheme.colorScheme.onSurface,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)),
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.9f),
PurrfectPalette.glowSecondary.copy(alpha = 0.85f)
)
)
),
modifier = Modifier
.then(if (targetBarWidth != null) Modifier.width(animatedBarWidth) else Modifier.fillMaxWidth())
.height(barHeight)
.drawBehind {
val radius = size.width * 0.62f
drawCircle(
@@ -263,7 +299,7 @@ class Navigation(
Box(
Modifier
.fillMaxWidth()
.height(82.dp)
.height(barHeight)
.clip(barShape)
.background(PurrfectPalette.cardOverlay)
.border(BorderStroke(1.dp, barBorder), barShape)
@@ -296,7 +332,7 @@ class Navigation(
)
}
)
Box(Modifier.fillMaxWidth().height(82.dp)) {
Box(Modifier.fillMaxWidth().height(barHeight)) {
var barWidthPx by remember { mutableStateOf(0f) }
val itemCount = selectedRoutes.size.coerceAtLeast(1)
val density = androidx.compose.ui.platform.LocalDensity.current
@@ -369,7 +405,7 @@ class Navigation(
.fillMaxHeight()
.width(indicatorWidth.coerceAtLeast(0.dp))
.offset(x = offsetX)
.padding(vertical = 10.dp, horizontal = 2.dp)
.padding(vertical = lerp(10.dp, 8.dp, focusFactor), horizontal = 2.dp)
.graphicsLayer { scaleX = scaleXAnim; scaleY = scaleYAnim }
) {
Box(
@@ -430,7 +466,10 @@ class Navigation(
contentDescription = null,
modifier = Modifier
.size(22.dp + 2.dp * selectionProgress)
.graphicsLayer { alpha = 0.65f + 0.35f * selectionProgress }
.graphicsLayer {
alpha = 0.65f + 0.35f * selectionProgress
translationY = iconTranslationY.toPx()
}
)
},
label = {
@@ -441,11 +480,15 @@ class Navigation(
textAlign = TextAlign.Center,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White.copy(alpha = 0.6f + 0.4f * selectionProgress),
color = Color.White.copy(alpha = (0.6f + 0.4f * selectionProgress) * labelAlpha),
maxLines = if (isLong) 2 else 1,
overflow = if (isLong) TextOverflow.Ellipsis else TextOverflow.Clip,
softWrap = isLong,
modifier = if (isLong) Modifier.widthIn(max = 90.dp).wrapContentWidth(Alignment.CenterHorizontally) else Modifier.wrapContentWidth(Alignment.CenterHorizontally)
modifier = (if (isLong) Modifier.widthIn(max = 90.dp).wrapContentWidth(Alignment.CenterHorizontally) else Modifier.wrapContentWidth(Alignment.CenterHorizontally))
.graphicsLayer {
alpha = labelAlpha
translationY = (-10 * focusFactor).dp.toPx() // Fall into icon
}
)
},
selected = isSelected,

View File

@@ -87,12 +87,12 @@ object Updater {
}.getOrNull()
private fun fetchLatestDebugCI() = runCatching {
val actionRuns = OkHttpClient().newCall(Request.Builder().url("https://api.github.com/repos/particle-box/PurrfectSnap/actions/runs?event=workflow_dispatch&branch=dev").build()).execute().use {
val actionRuns = OkHttpClient().newCall(Request.Builder().url("https://api.github.com/repos/particle-box/PurrfectSnap/actions/runs?branch=dev&status=success").build()).execute().use {
if (!it.isSuccessful) throw Throwable("Failed to fetch CI runs: ${it.code}")
JsonParser.parseString(it.body?.string()).asJsonObject
}
val debugRuns = actionRuns.getAsJsonArray("workflow_runs")?.mapNotNull { it.asJsonObject }?.filter { run ->
run.get("conclusion")?.takeIf { it.isJsonPrimitive }?.asString == "success" && run.getAsJsonPrimitive("path")?.asString == ".github/workflows/debug.yml"
run.getAsJsonPrimitive("name")?.asString == "PurrfectSnap Debug CI"
} ?: throw Throwable("No debug CI runs found")
val latestRun = debugRuns.firstOrNull() ?: throw Throwable("No debug CI runs found")
@@ -109,15 +109,22 @@ object Updater {
AbstractLogger.directError("Failed to fetch latest debug CI", it)
}.getOrNull()
private val cache = mutableMapOf<Channel, LatestRelease?>()
private val cache = mutableMapOf<Channel, Pair<Long, LatestRelease?>>()
fun getLatestRelease(channel: Channel): LatestRelease? {
return cache.getOrPut(channel) {
if (BuildConfig.DEBUG) {
fetchLatestDebugCI() ?: fetchLatestRelease(channel)
} else {
fetchLatestRelease(channel)
}
val cached = cache[channel]
// Use 24-hour TTL (Time To Live) for cache to optimize API calls
if (cached != null && (System.currentTimeMillis() - cached.first) < 24 * 60 * 60 * 1000) {
return cached.second
}
val result = if (channel == Channel.PRERELEASE) {
fetchLatestDebugCI() ?: fetchLatestRelease(channel)
} else {
fetchLatestRelease(channel)
}
cache[channel] = System.currentTimeMillis() to result
return result
}
}

View File

@@ -4,8 +4,8 @@ import android.net.Uri
import android.text.format.Formatter
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -38,7 +38,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -157,8 +157,8 @@ class FileImportsRoot: Routes.Route() {
.padding(horizontal = 10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = PaddingValues(
top = topBarHeight + 8.dp,
bottom = routes.bottomPadding + 16.dp
top = topBarHeight,
bottom = routes.bottomPadding
)
) {
item {
@@ -270,9 +270,7 @@ class FileImportsRoot: Routes.Route() {
title = titleText ?: translation["import_file_button"],
subtitle = null,
onBack = { routes.navController.popBackStack() },
modifier = Modifier.onGloballyPositioned {
topBarHeight = with(density) { it.size.height.toDp() }
}
modifier = Modifier.headerHeightTracker { topBarHeight = it }
)
}
}

View File

@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
@@ -38,6 +39,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.util.Motion
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
@@ -289,64 +292,137 @@ class LoggerHistoryRoot : Routes.Route() {
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
Column(modifier = Modifier.fillMaxSize()) {
FloatingTopBar(
title = context.translation["manager.routes.logger_history"] ?: "Logger History",
onBack = { routes.navController.popBackStack() }
)
val listState = rememberLazyListState()
val density = androidx.compose.ui.platform.LocalDensity.current
var controlsHeight by remember { mutableStateOf(100.dp) }
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp)) {
var expanded by remember { mutableStateOf(false) }
LaunchedEffect(listState.firstVisibleItemScrollOffset, listState.firstVisibleItemIndex) {
val offset = if (listState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else listState.firstVisibleItemScrollOffset
routes.navigation?.globalScrollOffset = offset
}
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp, bottom = 10.dp),
shape = RoundedCornerShape(22.dp),
color = PurrfectPalette.cardOverlayColor,
tonalElevation = 0.dp,
shadowElevation = 10.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Column(
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp)) {
Spacer(modifier = Modifier.height(controlsHeight))
var expanded by remember { mutableStateOf(false) }
Surface(
modifier = Modifier
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(22.dp))
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
.fillMaxWidth()
.padding(top = 4.dp, bottom = 10.dp),
shape = RoundedCornerShape(22.dp),
color = PurrfectPalette.cardOverlayColor,
tonalElevation = 0.dp,
shadowElevation = 10.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
Column(
modifier = Modifier
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(22.dp))
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
fun formatConversationInfo(conversationInfo: ConversationInfo?): String? {
if (conversationInfo == null) return null
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
) {
fun formatConversationInfo(conversationInfo: ConversationInfo?): String? {
if (conversationInfo == null) return null
return conversationInfo.groupTitle?.let {
translation.format("list_group_format", "name" to it)
} ?: conversationInfo.usernames.takeIf { it.size > 1 }?.let {
translation.format("list_friend_format", "name" to ("(" + it.joinToString(", ") + ")"))
} ?: context.database.findFriend(conversationInfo.conversationId)?.let {
translation.format("list_friend_format", "name" to "(" + (conversationInfo.usernames + listOf(it.mutableUsername)).toSet().joinToString(", ") + ")")
} ?: conversationInfo.usernames.firstOrNull()?.let {
translation.format("list_friend_format", "name" to "($it)")
return conversationInfo.groupTitle?.let {
translation.format("list_group_format", "name" to it)
} ?: conversationInfo.usernames.takeIf { it.size > 1 }?.let {
translation.format("list_friend_format", "name" to ("(" + it.joinToString(", ") + ")"))
} ?: context.database.findFriend(conversationInfo.conversationId)?.let {
translation.format("list_friend_format", "name" to "(" + (conversationInfo.usernames + listOf(it.mutableUsername)).toSet().joinToString(", ") + ")")
} ?: conversationInfo.usernames.firstOrNull()?.let {
translation.format("list_friend_format", "name" to "($it)")
}
}
}
val selectedConversationInfo by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(selectedConversation)) {
selectedConversation?.let {
conversationInfoCache.getOrPut(it) {
formatConversationInfo(loggerWrapper.getConversationInfo(it))
val selectedConversationInfo by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(selectedConversation)) {
selectedConversation?.let {
conversationInfoCache.getOrPut(it) {
formatConversationInfo(loggerWrapper.getConversationInfo(it))
}
}
}
OutlinedTextField(
value = selectedConversationInfo ?: translation["select_conversation_placeholder"],
onValueChange = {},
readOnly = true,
modifier = Modifier
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
.fillMaxWidth(),
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,
cursorColor = PurrfectPalette.glowSecondary
)
)
val conversations by rememberAsyncMutableState(defaultValue = emptyList<String>()) {
loggerWrapper.getAllConversations().toMutableList()
}
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
conversations.forEach { conversationId ->
DropdownMenuItem(onClick = {
selectedConversation = conversationId
expanded = false
}, text = {
val conversationInfo by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(conversationId)) {
conversationInfoCache.getOrPut(conversationId) {
formatConversationInfo(loggerWrapper.getConversationInfo(conversationId))
}
}
Text(
text = remember(conversationInfo) { conversationInfo ?: conversationId },
fontWeight = if (conversationId == selectedConversation) FontWeight.Bold else FontWeight.Normal,
color = Color.White,
overflow = TextOverflow.Ellipsis
)
})
}
}
}
OutlinedTextField(
value = selectedConversationInfo ?: translation["select_conversation_placeholder"],
onValueChange = {},
readOnly = true,
modifier = Modifier
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
.fillMaxWidth(),
value = stringFilter,
onValueChange = { stringFilter = it },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
placeholder = {
Text(
text = context.translation["manager.dialogs.add_friend.search_hint"] ?: "Search",
color = PurrfectPalette.textSecondary
)
},
leadingIcon = {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = null,
tint = PurrfectPalette.textSecondary
)
},
trailingIcon = if (stringFilter.isNotBlank()) {
{
IconButton(onClick = { stringFilter = "" }) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = translation["close_button_description"],
tint = PurrfectPalette.textSecondary
)
}
}
} else null,
colors = TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
@@ -358,160 +434,99 @@ class LoggerHistoryRoot : Routes.Route() {
)
)
val conversations by rememberAsyncMutableState(defaultValue = emptyList<String>()) {
loggerWrapper.getAllConversations().toMutableList()
}
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
conversations.forEach { conversationId ->
DropdownMenuItem(onClick = {
selectedConversation = conversationId
expanded = false
}, text = {
val conversationInfo by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(conversationId)) {
conversationInfoCache.getOrPut(conversationId) {
formatConversationInfo(loggerWrapper.getConversationInfo(conversationId))
}
}
Text(
text = remember(conversationInfo) { conversationInfo ?: conversationId },
fontWeight = if (conversationId == selectedConversation) FontWeight.Bold else FontWeight.Normal,
color = Color.White,
overflow = TextOverflow.Ellipsis
)
})
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
Text(
translation["reverse_order_checkbox"],
color = PurrfectPalette.textSecondary,
fontSize = 13.sp
)
Checkbox(
checked = reverseOrder,
onCheckedChange = { reverseOrder = it },
colors = CheckboxDefaults.colors(
checkedColor = PurrfectPalette.glowPrimary,
checkmarkColor = Color.White,
uncheckedColor = Color.White.copy(alpha = 0.35f)
)
)
}
}
}
OutlinedTextField(
value = stringFilter,
onValueChange = { stringFilter = it },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
placeholder = {
Text(
text = context.translation["manager.dialogs.add_friend.search_hint"] ?: "Search",
color = PurrfectPalette.textSecondary
)
},
leadingIcon = {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = null,
tint = PurrfectPalette.textSecondary
)
},
trailingIcon = if (stringFilter.isNotBlank()) {
{
IconButton(onClick = { stringFilter = "" }) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = translation["close_button_description"],
tint = PurrfectPalette.textSecondary
var hasReachedEnd by remember(selectedConversation, stringFilter, reverseOrder) { mutableStateOf(false) }
var lastFetchMessageTimestamp by remember(selectedConversation, stringFilter, reverseOrder) { mutableLongStateOf(if (reverseOrder) Long.MAX_VALUE else Long.MIN_VALUE) }
val messages = remember(selectedConversation, stringFilter, reverseOrder) { mutableStateListOf<LoggedMessage>() }
LazyColumn(
state = listState,
contentPadding = PaddingValues(bottom = routes.bottomPadding)
) {
items(messages) { message ->
MessageView(message)
}
item {
if (selectedConversation != null) {
if (hasReachedEnd) {
Text(translation["no_more_messages"], modifier = Modifier
.padding(8.dp)
.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.White)
} else {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
CircularProgressIndicator(
modifier = Modifier
.height(20.dp)
.padding(8.dp),
color = PurrfectPalette.glowSecondary,
strokeWidth = 2.dp
)
}
}
} 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,
cursorColor = PurrfectPalette.glowSecondary
)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
Text(
translation["reverse_order_checkbox"],
color = PurrfectPalette.textSecondary,
fontSize = 13.sp
)
Checkbox(
checked = reverseOrder,
onCheckedChange = { reverseOrder = it },
colors = CheckboxDefaults.colors(
checkedColor = PurrfectPalette.glowPrimary,
checkmarkColor = Color.White,
uncheckedColor = Color.White.copy(alpha = 0.35f)
)
)
}
}
}
var hasReachedEnd by remember(selectedConversation, stringFilter, reverseOrder) { mutableStateOf(false) }
var lastFetchMessageTimestamp by remember(selectedConversation, stringFilter, reverseOrder) { mutableLongStateOf(if (reverseOrder) Long.MAX_VALUE else Long.MIN_VALUE) }
val messages = remember(selectedConversation, stringFilter, reverseOrder) { mutableStateListOf<LoggedMessage>() }
LazyColumn(
contentPadding = PaddingValues(bottom = routes.bottomPadding)
) {
items(messages) { message ->
MessageView(message)
}
item {
if (selectedConversation != null) {
if (hasReachedEnd) {
Text(translation["no_more_messages"], modifier = Modifier
.padding(8.dp)
.fillMaxWidth(), textAlign = TextAlign.Center)
} else {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
CircularProgressIndicator(
modifier = Modifier
.height(20.dp)
.padding(8.dp),
color = PurrfectPalette.glowSecondary,
strokeWidth = 2.dp
)
}
}
}
LaunchedEffect(Unit, selectedConversation, stringFilter, reverseOrder) {
withContext(Dispatchers.IO) {
val newMessages = loggerWrapper.fetchMessages(
selectedConversation ?: return@withContext,
lastFetchMessageTimestamp,
30,
reverseOrder
) { messageData ->
if (stringFilter.isEmpty()) return@fetchMessages true
var isMatch = false
decodeMessage(messageData) { contentType, messageReader, _ ->
if (contentType == ContentType.CHAT) {
val content = messageReader.getString(2, 1) ?: return@decodeMessage
isMatch = content.contains(stringFilter, ignoreCase = true)
LaunchedEffect(Unit, selectedConversation, stringFilter, reverseOrder) {
withContext(Dispatchers.IO) {
val newMessages = loggerWrapper.fetchMessages(
selectedConversation ?: return@withContext,
lastFetchMessageTimestamp,
30,
reverseOrder
) { messageData ->
if (stringFilter.isEmpty()) return@fetchMessages true
var isMatch = false
decodeMessage(messageData) { contentType, messageReader, _ ->
if (contentType == ContentType.CHAT) {
val content = messageReader.getString(2, 1) ?: return@decodeMessage
isMatch = content.contains(stringFilter, ignoreCase = true)
}
}
isMatch
}
if (newMessages.isEmpty()) {
hasReachedEnd = true
return@withContext
}
lastFetchMessageTimestamp = newMessages.lastOrNull()?.sendTimestamp ?: return@withContext
withContext(Dispatchers.Main) {
messages.addAll(newMessages)
}
isMatch
}
if (newMessages.isEmpty()) {
hasReachedEnd = true
return@withContext
}
lastFetchMessageTimestamp = newMessages.lastOrNull()?.sendTimestamp ?: return@withContext
withContext(Dispatchers.Main) {
messages.addAll(newMessages)
}
}
}
}
}
}
}
FloatingTopBar(
title = context.translation["manager.routes.logger_history"] ?: "Logger History",
onBack = { routes.navController.popBackStack() },
scrollOffset = listState.firstVisibleItemScrollOffset + (listState.firstVisibleItemIndex * Motion.HEADER_MORPH_THRESHOLD.toInt()),
modifier = Modifier.headerHeightTracker { controlsHeight = it }
)
}
}
}

View File

@@ -1,18 +1,29 @@
@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
package me.eternal.purrfectsnap.ui.manager.pages
import me.eternal.purrfectsnap.ui.util.Motion
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.animation.animateContentSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Public
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -32,6 +43,7 @@ import me.eternal.purrfectsnap.storage.addRepo
import me.eternal.purrfectsnap.storage.getRepositories
import me.eternal.purrfectsnap.storage.removeRepo
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import okhttp3.OkHttpClient
class ManageReposSection: Routes.Route() {
@@ -165,44 +177,74 @@ class ManageReposSection: Routes.Route() {
val repositories = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = updateDispatcher) {
context.database.getRepositories(repoType)
}
val listState = rememberLazyListState()
val density = androidx.compose.ui.platform.LocalDensity.current
var controlsHeight by remember { mutableStateOf(100.dp) }
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 8.dp, top = 8.dp, end = 8.dp, bottom = 8.dp + routes.bottomPadding),
LaunchedEffect(listState.firstVisibleItemScrollOffset, listState.firstVisibleItemIndex) {
val offset = if (listState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else listState.firstVisibleItemScrollOffset
routes.navigation?.globalScrollOffset = offset
}
Box(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
item {
if (repositories.isEmpty()) {
Text(translation["no_repos_added"], modifier = Modifier
.padding(16.dp)
.fillMaxWidth(), fontSize = 15.sp, fontWeight = FontWeight.Light, textAlign = TextAlign.Center)
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
contentPadding = PaddingValues(start = 8.dp, top = controlsHeight, end = 8.dp, bottom = routes.bottomPadding),
) {
item {
if (repositories.isEmpty()) {
Text(translation["no_repos_added"], modifier = Modifier
.padding(16.dp)
.padding(top = 40.dp)
.fillMaxWidth(), fontSize = 15.sp, fontWeight = FontWeight.Light, textAlign = TextAlign.Center, color = Color.White)
}
}
}
items(repositories) { url ->
ElevatedCard(onClick = {
context.androidContext.copyToClipboard(url)
}, modifier = Modifier.animateContentSize()) {
Row(
items(repositories) { url ->
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
.padding(horizontal = 10.dp, vertical = 6.dp)
.clickable { context.androidContext.copyToClipboard(url) },
shape = RoundedCornerShape(20.dp),
color = Color.White.copy(alpha = 0.05f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
) {
Icon(Icons.Default.Public, contentDescription = null)
Text(text = url, modifier = Modifier.weight(1f), overflow = TextOverflow.Ellipsis, maxLines = 4, fontSize = 15.sp, lineHeight = 15.sp)
Button(
onClick = {
context.database.removeRepo(repoType, url)
coroutineScope.launch {
updateDispatcher.dispatch()
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(translation["remove_button"])
Icon(Icons.Default.Public, contentDescription = null, tint = Color.White)
Text(text = url, color = Color.White, modifier = Modifier.weight(1f), overflow = TextOverflow.Ellipsis, maxLines = 1, fontSize = 14.sp)
Button(
onClick = {
context.database.removeRepo(repoType, url)
coroutineScope.launch {
updateDispatcher.dispatch()
}
},
colors = ButtonDefaults.buttonColors(containerColor = Color.White.copy(alpha = 0.1f), contentColor = Color.White)
) {
Text(translation["remove_button"])
}
}
}
}
}
me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar(
title = remember(repoType) { translation.format("title", "type" to repoType) },
onBack = { routes.navController.popBackStack() },
scrollOffset = listState.firstVisibleItemScrollOffset + (listState.firstVisibleItemIndex * Motion.HEADER_MORPH_THRESHOLD.toInt()),
modifier = Modifier.headerHeightTracker { controlsHeight = it }
)
}
}
}

View File

@@ -14,8 +14,8 @@ import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
@@ -41,6 +41,8 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.util.Motion
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -68,6 +70,7 @@ import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.OnLifecycleEvent
import me.eternal.purrfectsnap.ui.util.coil.cacheKey
import me.eternal.purrfectsnap.ui.util.scaleOnPress
import java.io.File
import java.util.UUID
import kotlin.math.absoluteValue
@@ -105,7 +108,6 @@ class TasksRootSection : Routes.Route() {
runCatching {
pendingTask.updateProgress("Copying ${documentFile.name}")
context.androidContext.contentResolver.openInputStream(documentFile.uri)?.use { inputStream ->
//copy with progress
val length = documentFile.length().toFloat()
tempFile.outputStream().use { outputStream ->
val buffer = ByteArray(16 * 1024)
@@ -301,7 +303,7 @@ class TasksRootSection : Routes.Route() {
)
Column {
Text(
text = context.translation["delete_files_option"],
text = context.translation["delete_files_option"] ?: "Delete Files",
color = Color.White,
fontWeight = FontWeight.SemiBold
)
@@ -389,59 +391,7 @@ class TasksRootSection : Routes.Route() {
}
}
override val topBarActions: @Composable (RowScope.() -> Unit) = {
var showConfirmDialog by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
if (taskSelection.size > 1) {
val canMergeSelection by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(taskSelection.size)) {
taskSelection.all { it.second?.type?.contains("video") == true }
}
if (canMergeSelection) {
TopBarActionButton(
onClick = {
mergeSelection(taskSelection.toList().also {
taskSelection.clear()
}.map { it.first to it.second!! })
},
icon = Icons.Filled.Merge,
text = translation["merge_button"]
)
}
}
IconButton(onClick = {
showConfirmDialog = true
}) {
Icon(Icons.Filled.Delete, contentDescription = translation["clear_button_description"])
}
if (showConfirmDialog) {
var alsoDeleteFiles by remember { mutableStateOf(false) }
val isSelection = taskSelection.isNotEmpty()
val titleText = if (isSelection) {
translation.format("remove_selected_tasks_confirm", "count" to taskSelection.size.toString())
} else {
translation["remove_all_tasks_confirm"]
}
val messageText = if (isSelection) translation["remove_selected_tasks_title"] else translation["remove_all_tasks_title"]
TaskDangerDialog(
visible = showConfirmDialog,
title = titleText,
message = messageText,
showDeleteFiles = isSelection,
deleteFilesChecked = alsoDeleteFiles,
onToggleDeleteFiles = { alsoDeleteFiles = it },
onConfirm = {
showConfirmDialog = false
clearTasks(alsoDeleteFiles, coroutineScope)
},
onDismiss = { showConfirmDialog = false }
)
}
}
override val topBarActions: @Composable (RowScope.() -> Unit) = {}
@Composable
private fun TaskCard(modifier: Modifier, task: Task, pendingTask: PendingTask? = null) {
@@ -613,7 +563,7 @@ class TasksRootSection : Routes.Route() {
} else {
when {
!isDocumentFileReadable -> Icon(Icons.Filled.DeleteOutline, contentDescription = "File not found")
documentFileMimeType.contains("image") -> Icon(Icons.Filled.Image, contentDescription = "Image")
documentFileMimeType.contains("image") -> Icon(Icons.Filled.Photo, contentDescription = "Image")
documentFileMimeType.contains("video") -> Icon(Icons.Filled.Videocam, contentDescription = "Video")
documentFileMimeType.contains("audio") -> Icon(Icons.Filled.MusicNote, contentDescription = "Audio")
else -> Icon(Icons.Filled.FileCopy, contentDescription = "File")
@@ -674,11 +624,9 @@ class TasksRootSection : Routes.Route() {
modifier = Modifier.weight(1f),
) {
if (task.type == TaskType.SCHEDULED_SEND) {
// Professional design for scheduled send tasks
Column(
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
// Feature title
Text(
context.translation.getOrNull("scheduled_send_title") ?: "Scheduled Snaps",
style = MaterialTheme.typography.labelMedium,
@@ -686,7 +634,6 @@ class TasksRootSection : Routes.Route() {
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium
)
// Scheduled time without icon
Text(
task.title,
style = MaterialTheme.typography.titleMedium,
@@ -694,7 +641,6 @@ class TasksRootSection : Routes.Route() {
color = Color.White
)
// Recipients with icon
task.author?.takeIf { it != "null" }?.let { recipients ->
Row(
verticalAlignment = Alignment.Top,
@@ -844,6 +790,14 @@ class TasksRootSection : Routes.Route() {
override val content: @Composable (NavBackStackEntry) -> Unit = {
val scrollState = rememberLazyListState()
val density = androidx.compose.ui.platform.LocalDensity.current
var controlsHeight by remember { mutableStateOf(100.dp) }
LaunchedEffect(scrollState.firstVisibleItemScrollOffset, scrollState.firstVisibleItemIndex) {
val offset = if (scrollState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else scrollState.firstVisibleItemScrollOffset
routes.navigation?.globalScrollOffset = offset
}
val scope = rememberCoroutineScope()
recentTasks = remember { mutableStateListOf() }
var lastFetchedTaskId by remember { mutableStateOf(null as Long?) }
@@ -883,151 +837,98 @@ class TasksRootSection : Routes.Route() {
.background(PurrfectPalette.backgroundGradient)
) {
Column(modifier = Modifier.fillMaxSize()) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp)
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()),
shape = RoundedCornerShape(26.dp),
color = Color.White.copy(alpha = 0.07f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
val subtitle = if (activeTasks.isNotEmpty()) {
translation.format(
"summary_active",
"active" to activeTasks.size.toString(),
"recent" to recentTasks.size.toString()
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = context.translation["manager.routes.tasks"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
)
Text(
text = if (activeTasks.isNotEmpty()) {
translation.format(
"summary_active",
"active" to activeTasks.size.toString(),
"recent" to recentTasks.size.toString()
)
} else {
translation.format(
"summary_idle",
"recent" to recentTasks.size.toString()
)
},
color = PurrfectPalette.textSecondary,
fontSize = 12.sp
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (taskSelection.size > 1 && taskSelection.all { it.second?.type?.contains("video") == true }) {
Surface(
onClick = {
} else {
translation.format(
"summary_idle",
"recent" to recentTasks.size.toString()
)
}
me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar(
title = context.translation["manager.routes.tasks"] ?: "Tasks",
subtitle = subtitle,
scrollOffset = if (scrollState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else scrollState.firstVisibleItemScrollOffset,
modifier = Modifier.headerHeightTracker { controlsHeight = it },
actions = {
if (taskSelection.size > 1 && taskSelection.all { it.second?.type?.contains("video") == true }) {
Surface(
shape = RoundedCornerShape(50),
color = Color.White.copy(alpha = 0.1f),
modifier = Modifier
.padding(end = 8.dp)
.clickable {
mergeSelection(
taskSelection.toList().also { taskSelection.clear() }
.map { it.first to it.second!! }
)
},
shape = RoundedCornerShape(18.dp),
color = Color.White.copy(alpha = 0.08f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(listOf(PurrfectPalette.glowPrimary, PurrfectPalette.glowSecondary))
)
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(Icons.Filled.Merge, contentDescription = translation["merge_button"], tint = Color.White)
Text(translation["merge_button"], color = Color.White, fontWeight = FontWeight.SemiBold, fontSize = 12.sp)
}
}
}
Surface(
shape = RoundedCornerShape(18.dp),
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f))
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Filled.PlaylistAddCheckCircle, contentDescription = null, tint = Color.White)
Icon(
Icons.Filled.Merge,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(6.dp))
Text(
text = translation.format("running_count", "count" to activeTasks.size.toString()),
translation["merge_button"],
color = Color.White,
fontWeight = FontWeight.SemiBold,
fontSize = 12.sp
fontSize = 12.sp,
fontWeight = FontWeight.Bold
)
}
}
IconButton(onClick = { showConfirmDialog = true }) {
Icon(Icons.Filled.Delete, contentDescription = translation["clear_button_description"], tint = Color.White)
}
IconButton(onClick = {
if (taskSelection.isEmpty()) {
showConfirmDialog = true
} else {
showConfirmDialog = true
}
}) {
Icon(Icons.Filled.DeleteSweep, contentDescription = translation["clear_button_description"], tint = Color.White)
}
}
}
)
Surface(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 12.dp),
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.08f))
Spacer(modifier = Modifier.height(8.dp))
LazyColumn(
state = scrollState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 12.dp,
end = 12.dp,
top = controlsHeight,
bottom = routes.bottomPadding
),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
LazyColumn(
state = scrollState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 12.dp,
end = 12.dp,
top = 0.dp,
bottom = routes.bottomPadding + 16.dp
),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
if (activeTasks.isEmpty() && recentTasks.isEmpty()) {
TasksEmptyState(text = translation["no_tasks"])
}
item {
if (activeTasks.isEmpty() && recentTasks.isEmpty()) {
TasksEmptyState(text = translation["no_tasks"])
}
items(activeTasks, key = { it.taskId }) { pendingTask ->
TaskCard(modifier = Modifier.fillMaxWidth(), pendingTask.task, pendingTask = pendingTask)
}
items(recentTasks, key = { it.hash }) { task ->
TaskCard(modifier = Modifier.fillMaxWidth(), task)
}
item {
Spacer(modifier = Modifier.height(40.dp))
LaunchedEffect(remember { derivedStateOf { scrollState.firstVisibleItemIndex } }) {
fetchNewRecentTasks()
}
}
items(activeTasks, key = { it.taskId }) { pendingTask ->
TaskCard(modifier = Modifier.fillMaxWidth(), pendingTask.task, pendingTask = pendingTask)
}
items(recentTasks, key = { it.hash }) { task ->
TaskCard(modifier = Modifier.fillMaxWidth(), task)
}
item {
Spacer(modifier = Modifier.height(40.dp))
LaunchedEffect(remember { derivedStateOf { scrollState.firstVisibleItemIndex } }) {
fetchNewRecentTasks()
}
}
}
@@ -1045,8 +946,8 @@ class TasksRootSection : Routes.Route() {
TaskDangerDialog(
visible = showConfirmDialog,
title = titleText,
message = messageText,
title = titleText ?: "",
message = messageText ?: "",
showDeleteFiles = isSelection,
deleteFilesChecked = alsoDeleteFiles,
onToggleDeleteFiles = { alsoDeleteFiles = it },

View File

@@ -1,45 +1,24 @@
package me.eternal.purrfectsnap.ui.manager.pages.features
import me.eternal.purrfectsnap.ui.util.Motion
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
@@ -50,7 +29,9 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.util.saveFile
import me.eternal.purrfectsnap.storage.getLocationCoordinates
import org.json.JSONArray
@@ -147,189 +128,102 @@ class ConfigExportSummaryScreen : Routes.Route() {
parser.parse(context.config.exportToString(exportSensitiveData, includeSavedLocations, savedLocations))
}
val expandedState = remember { mutableStateMapOf<String, Boolean>() }
val listState = rememberLazyListState()
val density = androidx.compose.ui.platform.LocalDensity.current
var controlsHeight by remember { mutableStateOf(100.dp) }
Box(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
Column(
LazyColumn(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Bottom))
.padding(horizontal = 12.dp),
state = listState,
contentPadding = PaddingValues(
top = controlsHeight,
bottom = routes.bottomPadding
),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
shape = RoundedCornerShape(24.dp),
color = Color.Transparent,
tonalElevation = 0.dp,
shadowElevation = 12.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.45f)
)
)
)
) {
Row(
items(featuresByCategory.toList()) { (category, features) ->
val isExpanded = expandedState[category] ?: false
val rotationState by animateFloatAsState(targetValue = if (isExpanded) 180f else 0f)
Surface(
modifier = Modifier
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(24.dp))
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Button(
onClick = { routes.navController.popBackStack() },
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.28f),
contentColor = Color.White
)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
tint = Color.White,
modifier = Modifier.padding(end = 6.dp)
)
Text(context.translation["common.back"])
}
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.Center
) {
Text(
text = translation["title"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
)
}
Button(
onClick = {
routes.activityLauncher.saveFile("config.json", "application/json") { uri ->
runCatching {
context.androidContext.contentResolver.openOutputStream(android.net.Uri.parse(uri))?.use {
context.config.writeConfig()
context.config.exportToString(exportSensitiveData, includeSavedLocations, savedLocations).byteInputStream().copyTo(it)
context.shortToast(context.translation["manager.sections.features.config_export_success_toast"])
}
}.onFailure {
context.longToast(
context.translation.format(
"manager.sections.features.config_export_failure_toast",
"error" to it.message.toString()
)
)
}
}
},
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.32f),
contentColor = Color.White
)
) {
Icon(
imageVector = Icons.Default.ArrowDownward,
contentDescription = null,
tint = Color.White,
modifier = Modifier.padding(end = 6.dp)
)
Text(exportLabel)
}
}
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 12.dp),
contentPadding = PaddingValues(
top = 8.dp,
bottom = 16.dp + routes.bottomPadding
),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
items(featuresByCategory.toList()) { (category, features) ->
val isExpanded = expandedState[category] ?: false
val rotationState by animateFloatAsState(targetValue = if (isExpanded) 180f else 0f)
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable { expandedState[category] = !isExpanded },
shape = RoundedCornerShape(18.dp),
color = PurrfectPalette.cardOverlayColor,
tonalElevation = 0.dp,
shadowElevation = 10.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.4f),
PurrfectPalette.glowSecondary.copy(alpha = 0.32f)
)
.fillMaxWidth()
.clickable { expandedState[category] = !isExpanded },
shape = RoundedCornerShape(18.dp),
color = PurrfectPalette.cardOverlayColor,
tonalElevation = 0.dp,
shadowElevation = 10.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.4f),
PurrfectPalette.glowSecondary.copy(alpha = 0.32f)
)
)
) {
Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = category,
fontWeight = FontWeight.Bold,
fontSize = 17.sp,
color = Color.White
)
}
IconButton(onClick = { expandedState[category] = !isExpanded }) {
Icon(
imageVector = Icons.Default.KeyboardArrowDown,
contentDescription = translation["expand_button_description"],
modifier = Modifier.graphicsLayer(rotationZ = rotationState),
tint = Color.White
)
}
)
) {
Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = category,
fontWeight = FontWeight.Bold,
fontSize = 17.sp,
color = Color.White
)
}
AnimatedVisibility(visible = isExpanded) {
Column(
modifier = Modifier
.padding(top = 10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
features.forEachIndexed { index, feature ->
when (val parsedValue = parser.parseValue(feature.key, feature.value)) {
is List<*> -> {
IconButton(onClick = { expandedState[category] = !isExpanded }) {
Icon(
imageVector = Icons.Default.KeyboardArrowDown,
contentDescription = translation["expand_button_description"],
modifier = Modifier.graphicsLayer(rotationZ = rotationState),
tint = Color.White
)
}
}
AnimatedVisibility(visible = isExpanded) {
Column(
modifier = Modifier
.padding(top = 10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
features.forEachIndexed { index, feature ->
when (val parsedValue = parser.parseValue(feature.key, feature.value)) {
is List<*> -> {
Column(
modifier = Modifier.padding(start = (feature.indentation * 16).dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = feature.name,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
Column(
modifier = Modifier.padding(start = (feature.indentation * 16).dp),
modifier = Modifier.padding(start = 6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = feature.name,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
Column(
modifier = Modifier.padding(start = 6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
parsedValue.forEachIndexed { itemIndex, item ->
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
NumberBubble(itemIndex + 1)
Text(
text = item.toString(),
color = PurrfectPalette.textSecondary
)
}
parsedValue.forEachIndexed { itemIndex, item ->
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
NumberBubble(itemIndex + 1)
Text(
text = item.toString(),
color = PurrfectPalette.textSecondary
)
}
}
}
}
}
is String -> {
Column(
@@ -354,7 +248,6 @@ class ConfigExportSummaryScreen : Routes.Route() {
if (index < features.size - 1) {
Spacer(modifier = Modifier.height(6.dp))
}
}
}
}
}
@@ -362,6 +255,35 @@ class ConfigExportSummaryScreen : Routes.Route() {
}
}
}
FloatingTopBar(
title = translation["title"],
onBack = { routes.navController.popBackStack() },
scrollOffset = listState.firstVisibleItemScrollOffset + (listState.firstVisibleItemIndex * Motion.HEADER_MORPH_THRESHOLD.toInt()),
modifier = Modifier.headerHeightTracker { controlsHeight = it },
actions = {
IconButton(onClick = {
routes.activityLauncher.saveFile("config.json", "application/json") { uri ->
runCatching {
context.androidContext.contentResolver.openOutputStream(android.net.Uri.parse(uri))?.use {
context.config.writeConfig()
context.config.exportToString(exportSensitiveData, includeSavedLocations, savedLocations).byteInputStream().copyTo(it)
context.shortToast(context.translation["manager.sections.features.config_export_success_toast"])
}
}.onFailure {
context.longToast(
context.translation.format(
"manager.sections.features.config_export_failure_toast",
"error" to it.message.toString()
)
)
}
}
}) {
Icon(imageVector = Icons.Default.ArrowDownward, contentDescription = exportLabel, tint = Color.White)
}
}
)
}
}

View File

@@ -1,43 +1,24 @@
package me.eternal.purrfectsnap.ui.manager.pages.features
import me.eternal.purrfectsnap.ui.util.Motion
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
@@ -53,7 +34,9 @@ import me.eternal.purrfectsnap.bridge.location.LocationCoordinates
import me.eternal.purrfectsnap.storage.addOrUpdateLocationCoordinate
import me.eternal.purrfectsnap.storage.getLocationCoordinates
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import org.json.JSONArray
import org.json.JSONObject
import kotlin.math.abs
@@ -73,10 +56,6 @@ class ConfigImportConfirmationScreen : Routes.Route() {
private const val COORDINATE_TOLERANCE = 0.0001 // ~11 meters tolerance for de-duplication
}
/**
* Imports saved locations from JSON array into database with de-duplication.
* Only adds locations that don't already exist (within coordinate tolerance).
*/
private fun importSavedLocations(locationsArray: com.google.gson.JsonArray) {
val existingLocations = context.database.getLocationCoordinates()
@@ -87,14 +66,12 @@ class ConfigImportConfirmationScreen : Routes.Route() {
val longitude = locationObj.get("longitude")?.asDouble ?: continue
val radius = locationObj.get("radius")?.asDouble ?: 100.0
// Check for existing location with similar coordinates (de-duplication)
val existingMatch = existingLocations.find { existing ->
abs(existing.latitude - latitude) < COORDINATE_TOLERANCE &&
abs(existing.longitude - longitude) < COORDINATE_TOLERANCE
}
if (existingMatch == null) {
// No duplicate found, add as new location
val newLocation = LocationCoordinates().apply {
this.name = name
this.latitude = latitude
@@ -103,7 +80,6 @@ class ConfigImportConfirmationScreen : Routes.Route() {
}
context.database.addOrUpdateLocationCoordinate(null, newLocation)
}
// If duplicate exists, skip (do not update or delete existing)
}
}
@@ -233,111 +209,26 @@ class ConfigImportConfirmationScreen : Routes.Route() {
}
val expandedState = remember { mutableStateMapOf<String, Boolean>() }
val importLabel = translation["confirm_button"]
val listState = rememberLazyListState()
val density = androidx.compose.ui.platform.LocalDensity.current
var controlsHeight by remember { mutableStateOf(100.dp) }
Box(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
Column(
LazyColumn(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.padding(horizontal = 12.dp),
state = listState,
contentPadding = PaddingValues(
top = controlsHeight,
bottom = routes.bottomPadding
),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
shape = RoundedCornerShape(24.dp),
color = Color.Transparent,
tonalElevation = 0.dp,
shadowElevation = 12.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.45f)
)
)
)
) {
Row(
modifier = Modifier
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(24.dp))
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Button(
onClick = { routes.navController.popBackStack() },
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.28f),
contentColor = Color.White
)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
tint = Color.White,
modifier = Modifier.padding(end = 6.dp)
)
Text(context.translation["common.back"])
}
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.Center
) {
Text(
text = translation["title"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
)
}
Button(
onClick = {
routes.configJsonForImport?.let { json ->
runCatching {
val savedLocationsJson = context.config.loadFromString(json)
// Import saved locations if present in the JSON
savedLocationsJson?.let { locationsArray ->
importSavedLocations(locationsArray)
}
}.onFailure { err ->
context.longToast(
context.translation.format(
"config_import_failure_toast",
"error" to (err.message ?: context.translation["common.unknown_error"])
)
)
}
context.shortToast(translation["config_imported_toast"])
context.coroutineScope.launch(Dispatchers.Main) {
routes.features.navigateReload()
}
}
},
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.3f),
contentColor = Color.White
)
) {
Text(importLabel)
}
}
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 12.dp),
contentPadding = PaddingValues(
top = 8.dp,
bottom = 16.dp + routes.bottomPadding
),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
items(featuresByCategory.toList()) { (category, features) ->
val isExpanded = expandedState[category] ?: false
val rotationState by animateFloatAsState(targetValue = if (isExpanded) 180f else 0f)
@@ -439,15 +330,46 @@ class ConfigImportConfirmationScreen : Routes.Route() {
}
if (index < features.size - 1) {
Spacer(modifier = Modifier.height(6.dp))
}
}
}
}
}
}
}
}
}
}
}
}
FloatingTopBar(
title = translation["title"],
onBack = { routes.navController.popBackStack() },
scrollOffset = listState.firstVisibleItemScrollOffset + (listState.firstVisibleItemIndex * Motion.HEADER_MORPH_THRESHOLD.toInt()),
modifier = Modifier.headerHeightTracker { controlsHeight = it },
actions = {
IconButton(onClick = {
routes.configJsonForImport?.let { json ->
runCatching {
val savedLocationsJson = context.config.loadFromString(json)
savedLocationsJson?.let { locationsArray ->
importSavedLocations(locationsArray)
}
}.onFailure { err ->
context.longToast(
context.translation.format(
"config_import_failure_toast",
"error" to (err.message ?: context.translation["common.unknown_error"])
)
)
}
context.shortToast(translation["config_imported_toast"])
context.coroutineScope.launch(Dispatchers.Main) {
routes.features.navigateReload()
}
}
}) {
Icon(imageVector = Icons.Default.Check, contentDescription = importLabel, tint = Color.White)
}
}
)
}
}

View File

@@ -26,7 +26,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
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.TextOverflow
@@ -48,6 +47,7 @@ import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.manager.pages.social.AddFriendDialog
import me.eternal.purrfectsnap.ui.manager.pages.social.AddFriendDialog.Actions
@@ -223,25 +223,17 @@ class ManageRuleFeature : Routes.Route() {
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
val density = LocalDensity.current
var topBarHeight by remember { mutableStateOf(96.dp) }
FloatingTopBar(
title = remember { context.translation[propertyKeyPair.key.propertyName()] },
onBack = { routes.navController.popBackStack() },
modifier = Modifier
.zIndex(2f)
.onGloballyPositioned {
val newHeight = with(density) { it.size.height.toDp() }
if (newHeight != topBarHeight) topBarHeight = newHeight
}
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(top = topBarHeight + 10.dp)
.padding(horizontal = 12.dp, vertical = 10.dp)
.verticalScroll(rememberScrollState()),
val scrollState = rememberScrollState()
val density = androidx.compose.ui.platform.LocalDensity.current
var controlsHeight by remember { mutableStateOf(100.dp) }
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(top = controlsHeight)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
val headerShape = RoundedCornerShape(22.dp)
@@ -397,6 +389,13 @@ class ManageRuleFeature : Routes.Route() {
Spacer(modifier = Modifier.height(routes.bottomPadding))
}
FloatingTopBar(
title = remember { context.translation[propertyKeyPair.key.propertyName()] },
onBack = { routes.navController.popBackStack() },
scrollOffset = scrollState.value,
modifier = Modifier.headerHeightTracker { controlsHeight = it }
)
}
}
}

View File

@@ -1,40 +1,19 @@
package me.eternal.purrfectsnap.ui.manager.pages.home
import android.os.SystemClock
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
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.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -44,6 +23,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
@@ -57,9 +37,12 @@ import me.eternal.purrfectsnap.common.util.ktx.openLink
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import android.os.SystemClock
import me.eternal.purrfectsnap.ui.util.PurrfectMarqueeText
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
class HomeAbout : Routes.Route() {
override val translation by lazy { context.translation.getCategory("manager.sections.home_about") }
override val content: @Composable (NavBackStackEntry) -> Unit = {
val avenirNext = remember {
FontFamily(Font(R.font.avenir_next_medium, FontWeight.Medium))
@@ -67,9 +50,7 @@ class HomeAbout : Routes.Route() {
val scrollState = rememberScrollState()
val aboutStory = remember { translation["about_story"] }
val pagePadding = 16.dp
val bottomPadding = routes.bottomPadding +
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
24.dp
val bottomPadding = routes.bottomPadding
val tapSource = remember { MutableInteractionSource() }
val tapTimeoutMs = 1500L
val tapCount = remember { mutableIntStateOf(0) }
@@ -79,24 +60,23 @@ class HomeAbout : Routes.Route() {
context.shortToast(translation["about_magic_toast"])
}
LaunchedEffect(scrollState.value) {
routes.navigation?.globalScrollOffset = scrollState.value
}
Box(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
var controlsHeight by remember { mutableStateOf(100.dp) }
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(bottom = bottomPadding)
.padding(top = controlsHeight, bottom = bottomPadding)
) {
FloatingTopBar(
title = routeInfo.translatedKey?.value ?: translation["manager.routes.home_about"],
onBack = { routes.navController.popBackStack() }
)
Spacer(modifier = Modifier.height(8.dp))
Surface(
modifier = Modifier
.padding(horizontal = pagePadding)
@@ -156,13 +136,13 @@ class HomeAbout : Routes.Route() {
verticalAlignment = Alignment.CenterVertically
) {
DeveloperCard(
name = "ΞTΞRNAL",
name = translation["about_dev_external"],
imageRes = R.drawable.pfp_external,
avenirNext = avenirNext,
modifier = Modifier.weight(1f)
)
DeveloperCard(
name = "<RSR/>",
name = translation["about_dev_rsr"],
imageRes = R.drawable.pfp_rsr,
avenirNext = avenirNext,
modifier = Modifier.weight(1f)
@@ -176,7 +156,8 @@ class HomeAbout : Routes.Route() {
Surface(
modifier = Modifier
.padding(horizontal = pagePadding)
.fillMaxWidth(),
.fillMaxWidth()
.widthIn(max = 500.dp),
shape = RoundedCornerShape(26.dp),
color = Color.Transparent,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)),
@@ -186,20 +167,24 @@ class HomeAbout : Routes.Route() {
Column(
modifier = Modifier
.background(PurrfectPalette.cardOverlay)
.padding(horizontal = 20.dp, vertical = 18.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
.padding(horizontal = 24.dp, vertical = 20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = translation["about_story_title"],
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color.White
color = Color.White,
textAlign = TextAlign.Center
)
Text(
text = aboutStory,
fontSize = 14.sp,
color = PurrfectPalette.textSecondary,
lineHeight = 20.sp
textAlign = TextAlign.Justify,
lineHeight = 22.sp,
modifier = Modifier.fillMaxWidth()
)
}
}
@@ -278,9 +263,14 @@ class HomeAbout : Routes.Route() {
}
}
}
Spacer(modifier = Modifier.height(32.dp))
}
FloatingTopBar(
title = context.translation["manager.routes.home_about"] ?: "About Us",
onBack = { routes.navController.popBackStack() },
scrollOffset = scrollState.value,
modifier = Modifier.headerHeightTracker { controlsHeight = it }
)
}
}
@@ -316,10 +306,10 @@ class HomeAbout : Routes.Route() {
) {
Box(
modifier = Modifier
.size(82.dp)
.size(76.dp)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.1f))
.border(2.dp, imageRing, CircleShape)
.border(1.5.dp, imageRing, CircleShape)
) {
Image(
painter = painterResource(id = imageRes),
@@ -328,16 +318,17 @@ class HomeAbout : Routes.Route() {
modifier = Modifier.fillMaxSize()
)
}
Text(
PurrfectMarqueeText(
text = name,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
fontFamily = avenirNext
),
color = Color.White,
fontFamily = avenirNext,
maxLines = 1,
overflow = TextOverflow.Ellipsis
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
}

View File

@@ -64,8 +64,11 @@ import me.eternal.purrfectsnap.LogReader
import me.eternal.purrfectsnap.common.logger.LogChannel
import me.eternal.purrfectsnap.common.logger.LogLevel
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.util.Motion
import me.eternal.purrfectsnap.ui.util.pullrefresh.PullRefreshIndicator
import me.eternal.purrfectsnap.ui.util.pullrefresh.pullRefresh
import me.eternal.purrfectsnap.ui.util.pullrefresh.rememberPullRefreshState
@@ -73,9 +76,9 @@ import me.eternal.purrfectsnap.ui.util.saveFile
import me.eternal.purrfectsnap.common.util.ktx.copyToClipboard
class HomeLogs : Routes.Route() {
private val logListState by lazy { LazyListState(0) }
private val logListState = LazyListState()
private lateinit var activityLauncherHelper: ActivityLauncherHelper
private val externalRefreshTick = mutableStateOf(0)
private val externalRefreshTick = mutableIntStateOf(0)
override val init: () -> Unit = {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
}
@@ -109,18 +112,19 @@ class HomeLogs : Routes.Route() {
override val topBarActions: @Composable (RowScope.() -> Unit) = {}
override val content: @Composable (NavBackStackEntry) -> Unit = {
val coroutineScope = rememberCoroutineScope()
var controlsHeight by remember { mutableStateOf(100.dp) }
val composeContext = LocalContext.current
var logReader by remember { mutableStateOf<LogReader?>(null) }
val visibleLogs = remember { mutableStateListOf<LogLine>() }
val mainExecutor = remember { context.androidContext.mainExecutor }
var isRefreshing by remember { mutableStateOf(false) }
fun refreshLogs() {
coroutineScope.launch {
val readerResult = withContext(Dispatchers.IO) {
runCatching {
context.log.newReader { line ->
if (shouldHideLog(line)) return@newReader
mainExecutor.execute {
coroutineScope.launch(Dispatchers.Main) {
visibleLogs.add(line)
}
}
@@ -141,8 +145,7 @@ class HomeLogs : Routes.Route() {
}
delay(220)
if (visibleLogs.isNotEmpty()) {
val targetIndex = (visibleLogs.size - 1).coerceAtLeast(0)
logListState.scrollToItem(targetIndex)
logListState.scrollToItem((visibleLogs.size - 1).coerceAtLeast(0))
}
isRefreshing = false
}
@@ -167,49 +170,98 @@ class HomeLogs : Routes.Route() {
.background(PurrfectPalette.backgroundGradient)
.pullRefresh(pullRefreshState)
) {
Column(modifier = Modifier.fillMaxSize()) {
LogsFloatingBar(
isRefreshing = isRefreshing,
onRefresh = {
isRefreshing = true
refreshLogs()
},
onExport = { exportLogs() },
onClear = { clearLogsAndReload() }
)
Spacer(modifier = Modifier.height(12.dp))
Surface(
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp),
shape = RoundedCornerShape(24.dp),
color = Color.White.copy(alpha = 0.04f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
) {
if (visibleLogs.isEmpty() && logReader != null) {
EmptyLogsState()
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize(),
state = logListState,
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(
start = 8.dp,
end = 8.dp,
top = 12.dp,
bottom = routes.bottomPadding + 22.dp
)
) {
items(visibleLogs, key = { it.hashCode() }) { line ->
LogEntryCard(line = line, composeContext = composeContext)
}
var showDropDown by remember { mutableStateOf(false) }
Surface(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 12.dp),
shape = RoundedCornerShape(24.dp),
color = Color.White.copy(alpha = 0.04f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
) {
if (visibleLogs.isEmpty() && logReader != null) {
EmptyLogsState()
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize(),
state = logListState,
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(
start = 8.dp,
end = 8.dp,
top = controlsHeight,
bottom = routes.bottomPadding + 12.dp
)
) {
items(visibleLogs, key = { it.hashCode() }) { line ->
LogEntryCard(line = line, composeContext = composeContext)
}
}
}
}
FloatingTopBar(
title = context.translation["manager.routes.home_logs"] ?: "Logs",
onBack = { routes.navController.popBackStack() },
scrollOffset = if (logListState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else logListState.firstVisibleItemScrollOffset,
modifier = Modifier.headerHeightTracker { controlsHeight = it },
actions = {
if (isRefreshing) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = Color.White
)
}
IconButton(onClick = { isRefreshing = true; refreshLogs() }) {
Icon(Icons.Filled.Refresh, contentDescription = "Refresh", tint = Color.White)
}
Box {
IconButton(onClick = { showDropDown = true }) {
Icon(Icons.Filled.MoreVert, contentDescription = null, tint = Color.White)
}
DropdownMenu(
expanded = showDropDown,
onDismissRequest = { showDropDown = false },
offset = DpOffset(0.dp, 8.dp),
containerColor = Color(0xFF161821),
tonalElevation = 8.dp,
shadowElevation = 12.dp,
shape = RoundedCornerShape(14.dp)
) {
DropdownMenuItem(
onClick = {
clearLogsAndReload()
showDropDown = false
},
leadingIcon = { Icon(Icons.Filled.DeleteSweep, contentDescription = null, tint = PurrfectPalette.glowPrimary) },
text = { Text(translation["clear_logs_button"], color = Color.White) },
colors = MenuDefaults.itemColors(
textColor = Color.White,
leadingIconColor = PurrfectPalette.glowPrimary
)
)
DropdownMenuItem(
onClick = {
exportLogs()
showDropDown = false
},
leadingIcon = { Icon(Icons.Filled.Download, contentDescription = null, tint = PurrfectPalette.glowSecondary) },
text = { Text(translation["export_logs_button"], color = Color.White) },
colors = MenuDefaults.itemColors(
textColor = Color.White,
leadingIconColor = PurrfectPalette.glowSecondary
)
)
}
}
}
)
PullRefreshIndicator(
refreshing = isRefreshing,
state = pullRefreshState,
@@ -270,116 +322,6 @@ class HomeLogs : Routes.Route() {
}
}
@Composable
private fun LogsFloatingBar(
isRefreshing: Boolean,
onRefresh: () -> Unit,
onExport: () -> Unit,
onClear: () -> Unit
) {
var showDropDown by remember { mutableStateOf(false) }
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp)
.padding(top = topPadding),
shape = RoundedCornerShape(28.dp),
color = Color.White.copy(alpha = 0.07f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.6f),
PurrfectPalette.glowSecondary.copy(alpha = 0.45f)
)
)
)
) {
Column(
modifier = Modifier.padding(horizontal = 18.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
IconButton(onClick = { routes.navController.popBackStack() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, tint = Color.White)
}
Text(
text = routeInfo.translatedKey?.value ?: translation["manager.routes.home_logs"],
color = PurrfectPalette.textPrimary,
fontSize = 18.sp,
fontWeight = FontWeight.ExtraBold
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
if (isRefreshing) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = PurrfectPalette.glowSecondary
)
}
IconButton(onClick = onRefresh) {
Icon(Icons.Filled.Refresh, contentDescription = "Refresh", tint = Color.White)
}
Box {
IconButton(onClick = { showDropDown = true }) {
Icon(Icons.Filled.MoreVert, contentDescription = null, tint = PurrfectPalette.glowSecondary)
}
DropdownMenu(
expanded = showDropDown,
onDismissRequest = { showDropDown = false },
offset = DpOffset(0.dp, 8.dp),
containerColor = Color(0xFF161821),
tonalElevation = 8.dp,
shadowElevation = 12.dp,
shape = RoundedCornerShape(14.dp)
) {
DropdownMenuItem(
onClick = {
onClear()
showDropDown = false
},
leadingIcon = { Icon(Icons.Filled.DeleteSweep, contentDescription = null, tint = PurrfectPalette.glowPrimary) },
text = { Text(translation["clear_logs_button"], color = Color.White) },
colors = MenuDefaults.itemColors(
textColor = Color.White,
leadingIconColor = PurrfectPalette.glowPrimary
)
)
DropdownMenuItem(
onClick = {
onExport()
showDropDown = false
},
leadingIcon = { Icon(Icons.Filled.Download, contentDescription = null, tint = PurrfectPalette.glowSecondary) },
text = { Text(translation["export_logs_button"], color = Color.White) },
colors = MenuDefaults.itemColors(
textColor = Color.White,
leadingIconColor = PurrfectPalette.glowSecondary
)
)
}
}
}
}
}
}
}
@Composable
private fun EmptyLogsState() {
Column(

View File

@@ -55,6 +55,7 @@ import me.eternal.purrfectsnap.task.UpdateCheckWorker
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.setup.Requirements
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
import me.eternal.purrfectsnap.ui.util.AlertDialogs
@@ -203,7 +204,7 @@ class HomeSettings : Routes.Route() {
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(text = text, modifier = Modifier.padding(start = 26.dp, end = 16.dp), fontSize = 14.sp)
Text(text = text, modifier = Modifier.padding(start = 26.dp, end = 16.dp), fontSize = 14.sp, color = Color.White)
Switch(
checked = value,
onCheckedChange = null,
@@ -235,7 +236,7 @@ class HomeSettings : Routes.Route() {
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(text = text, modifier = Modifier.padding(start = 26.dp, end = 16.dp), fontSize = 14.sp)
Text(text = text, modifier = Modifier.padding(start = 26.dp, end = 16.dp), fontSize = 14.sp, color = Color.White)
Switch(
checked = value,
onCheckedChange = null,
@@ -280,8 +281,8 @@ class HomeSettings : Routes.Route() {
Column(
modifier = Modifier.weight(1f),
) {
Text(text = context.translation["actions.$key.name"], fontSize = 16.sp, fontWeight = FontWeight.Bold, lineHeight = 20.sp)
context.translation.getOrNull("actions.$key.description")?.let { Text(text = it, fontSize = 12.sp, fontWeight = FontWeight.Light, lineHeight = 15.sp) }
Text(text = context.translation["actions.$key.name"], fontSize = 16.sp, fontWeight = FontWeight.Bold, lineHeight = 20.sp, color = Color.White)
context.translation.getOrNull("actions.$key.description")?.let { Text(text = it, fontSize = 12.sp, fontWeight = FontWeight.Light, lineHeight = 15.sp, color = PurrfectPalette.textSecondary) }
}
IconButton(onClick = { takeAction() },
modifier = Modifier.padding(end = 2.dp)
@@ -289,7 +290,8 @@ class HomeSettings : Routes.Route() {
Icon(
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
contentDescription = context.translation.getOrNull("actions.$key.name"),
modifier = Modifier.size(24.dp)
modifier = Modifier.size(24.dp),
tint = Color.White
)
}
}
@@ -313,6 +315,11 @@ class HomeSettings : Routes.Route() {
val contextC = LocalContext.current
val scope = rememberCoroutineScope()
val scrollState = rememberScrollState()
LaunchedEffect(scrollState.value) {
routes.navigation?.globalScrollOffset = scrollState.value
}
val positiveLabel = context.translation["button.positive"]
val negativeLabel = context.translation["button.negative"]
val importLabel = context.translation["button.import"]
@@ -372,109 +379,27 @@ class HomeSettings : Routes.Route() {
}
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
val density = androidx.compose.ui.platform.LocalDensity.current
var controlsHeight by remember { mutableStateOf(100.dp) }
Box(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
if (showResetSetupDialog) {
AestheticDialog(
onDismissRequest = { showResetSetupDialog = false },
title = translation["reset_setup_dialog_title"],
text = translation["reset_setup_dialog_text"],
icon = Icons.Filled.Warning,
confirmButtonText = positiveLabel,
dismissButtonText = negativeLabel,
onConfirm = {
showResetSetupDialog = false
context.sharedPreferences.edit()
.remove("setup_in_progress")
.remove("setup_current_route")
.remove("setup_skip_patch")
.remove("setup_install_mode")
.apply()
context.config.reset()
context.config.writeConfig()
val intent = android.content.Intent(
context.androidContext,
me.eternal.purrfectsnap.ui.setup.SetupActivity::class.java
)
intent.flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or
android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK
context.androidContext.startActivity(intent)
routes.navController.popBackStack()
},
onDismiss = { showResetSetupDialog = false },
showCloseButton = false
)
}
Column(
modifier = Modifier.fillMaxSize()
) {
Spacer(modifier = Modifier.height(topPadding))
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
shape = RoundedCornerShape(26.dp),
color = Color.White.copy(alpha = 0.07f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
),
contentColor = Color.White
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
IconButton(onClick = { routes.navController.popBackStack() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
tint = Color.White
)
}
Text(
text = translation["manager.routes.home_settings"],
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
)
IconButton(onClick = { routes.navigation?.openBottomBarCustomization = true }) {
Icon(
imageVector = Icons.Filled.Tune,
contentDescription = null,
tint = Color.White.copy(alpha = 0.85f)
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(top = controlsHeight, bottom = routes.bottomPadding)
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
GlassCard {
RowTitle(title = translation["actions_title"])
EnumAction.entries.forEach { enumAction ->
@@ -486,56 +411,54 @@ class HomeSettings : Routes.Route() {
GlassCard {
RowTitle(title = translation["ui_settings_title"])
ShiftedRow {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 55.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = translation["haptic_feedback_label"])
var hapticFeedbackEnabled by remember { mutableStateOf(context.config.root.global.uiSettings.hapticFeedback.getNullable() ?: true) }
val hapticFeedback = LocalHapticFeedback.current
Switch(
checked = hapticFeedbackEnabled,
onCheckedChange = {
if (it) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
hapticFeedbackEnabled = it
context.config.root.global.uiSettings.hapticFeedback.set(it)
context.config.writeConfig()
},
modifier = Modifier.padding(end = 26.dp),
colors = purrfectSwitchColors()
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 55.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = translation["use_system_toasts_label"])
var useSystemToasts by remember { mutableStateOf(context.config.root.global.uiSettings.useSystemToasts.getNullable() ?: false) }
val hapticFeedback = LocalHapticFeedback.current
Switch(
checked = useSystemToasts,
onCheckedChange = {
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
useSystemToasts = it
context.config.root.global.uiSettings.useSystemToasts.set(it)
context.config.writeConfig()
},
modifier = Modifier.padding(end = 26.dp),
colors = purrfectSwitchColors()
)
}
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 55.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = translation["haptic_feedback_label"], color = Color.White, modifier = Modifier.padding(start = 26.dp, end = 16.dp))
var hapticFeedbackEnabled by remember { mutableStateOf(context.config.root.global.uiSettings.hapticFeedback.getNullable() ?: true) }
val hapticFeedback = LocalHapticFeedback.current
Switch(
checked = hapticFeedbackEnabled,
onCheckedChange = {
if (it) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
hapticFeedbackEnabled = it
context.config.root.global.uiSettings.hapticFeedback.set(it)
context.config.writeConfig()
},
modifier = Modifier.padding(end = 26.dp),
colors = purrfectSwitchColors()
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 55.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = translation["use_system_toasts_label"], color = Color.White, modifier = Modifier.padding(start = 26.dp, end = 16.dp))
var useSystemToasts by remember { mutableStateOf(context.config.root.global.uiSettings.useSystemToasts.getNullable() ?: false) }
val hapticFeedback = LocalHapticFeedback.current
Switch(
checked = useSystemToasts,
onCheckedChange = {
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
useSystemToasts = it
context.config.root.global.uiSettings.useSystemToasts.set(it)
context.config.writeConfig()
},
modifier = Modifier.padding(end = 26.dp),
colors = purrfectSwitchColors()
)
}
}
}
@@ -546,31 +469,29 @@ class HomeSettings : Routes.Route() {
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"])
val hapticFeedback = LocalHapticFeedback.current
Switch(
checked = autoUpdateCheck,
onCheckedChange = {
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
autoUpdateCheck = it
context.config.root.global.updateSettings.autoUpdateCheck.set(it)
context.config.writeConfig()
scheduleUpdateCheck()
},
modifier = Modifier.padding(end = 26.dp),
colors = purrfectSwitchColors()
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 55.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = translation["auto_update_check"], color = Color.White, modifier = Modifier.padding(start = 26.dp, end = 16.dp))
val hapticFeedback = LocalHapticFeedback.current
Switch(
checked = autoUpdateCheck,
onCheckedChange = {
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
autoUpdateCheck = it
context.config.root.global.updateSettings.autoUpdateCheck.set(it)
context.config.writeConfig()
scheduleUpdateCheck()
},
modifier = Modifier.padding(end = 26.dp),
colors = purrfectSwitchColors()
)
}
AnimatedVisibility(visible = autoUpdateCheck) {
val spacingModifier = Modifier
@@ -628,12 +549,14 @@ class HomeSettings : Routes.Route() {
text = translation["reset_setup_action"],
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
lineHeight = 20.sp
lineHeight = 20.sp,
color = Color.White
)
Icon(
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
contentDescription = translation["reset_setup_action"],
modifier = Modifier.padding(end = 14.dp)
modifier = Modifier.padding(end = 14.dp),
tint = Color.White
)
}
}
@@ -933,35 +856,80 @@ class HomeSettings : Routes.Route() {
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f)),
shape = RoundedCornerShape(14.dp)
) {
Icon(Icons.Default.DeleteSweep, contentDescription = null, modifier = Modifier.size(18.dp))
Icon(Icons.Default.DeleteSweep, contentDescription = null, modifier = Modifier.size(18.dp), tint = Color.White)
Spacer(modifier = Modifier.width(8.dp))
Text(translation["clear_button"])
}
}
ShiftedRow {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
PremiumPreferenceToggle(
context.sharedPreferences,
key = "test_mode",
text = translation["test_mode_label"],
defaultValue = true,
confirmDisableTitle = translation["purr_aura_disable_title"],
confirmDisableText = translation["purr_aura_disable_text"]
)
PreferenceToggle(context.sharedPreferences, key = "disable_feature_loading", text = translation["disable_feature_loading_label"])
PreferenceToggle(context.sharedPreferences, key = "disable_mapper", text = translation["disable_auto_mapper_label"])
PreferenceToggle(context.sharedPreferences, key = "disable_bypass_indicator", text = translation["disable_bypass_indicator_label"])
}
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
PremiumPreferenceToggle(
context.sharedPreferences,
key = "test_mode",
text = translation["test_mode_label"],
defaultValue = true,
confirmDisableTitle = translation["purr_aura_disable_title"],
confirmDisableText = translation["purr_aura_disable_text"]
)
PreferenceToggle(context.sharedPreferences, key = "disable_feature_loading", text = translation["disable_feature_loading_label"])
PreferenceToggle(context.sharedPreferences, key = "disable_mapper", text = translation["disable_auto_mapper_label"])
PreferenceToggle(context.sharedPreferences, key = "disable_bypass_indicator", text = translation["disable_bypass_indicator_label"])
}
}
}
Spacer(modifier = Modifier.height(routes.bottomPadding + 12.dp))
}
}
me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar(
title = translation["manager.routes.home_settings"] ?: "Settings",
onBack = { routes.navController.popBackStack() },
scrollOffset = scrollState.value,
modifier = Modifier.headerHeightTracker { controlsHeight = it },
actions = {
IconButton(onClick = { routes.navigation?.openBottomBarCustomization = true }) {
Icon(
imageVector = Icons.Filled.Tune,
contentDescription = null,
tint = Color.White.copy(alpha = 0.85f)
)
}
}
)
if (showResetSetupDialog) {
AestheticDialog(
onDismissRequest = { showResetSetupDialog = false },
title = translation["reset_setup_dialog_title"],
text = translation["reset_setup_dialog_text"],
icon = Icons.Filled.Warning,
confirmButtonText = positiveLabel,
dismissButtonText = negativeLabel,
onConfirm = {
showResetSetupDialog = false
context.sharedPreferences.edit()
.remove("setup_in_progress")
.remove("setup_current_route")
.remove("setup_skip_patch")
.remove("setup_install_mode")
.apply()
context.config.reset()
context.config.writeConfig()
val intent = android.content.Intent(
context.androidContext,
me.eternal.purrfectsnap.ui.setup.SetupActivity::class.java
)
intent.flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or
android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK
context.androidContext.startActivity(intent)
routes.navController.popBackStack()
},
onDismiss = { showResetSetupDialog = false },
showCloseButton = false
)
}
}
}
}
}

View File

@@ -73,6 +73,7 @@ import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.components.AestheticEmptyState
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import okhttp3.OkHttpClient
class ManageScriptReposSection : Routes.Route() {
@@ -296,10 +297,7 @@ class ManageScriptReposSection : Routes.Route() {
onBack = { routes.navController.popBackStack() },
modifier = Modifier
.zIndex(2f)
.onGloballyPositioned {
val newHeight = with(density) { it.size.height.toDp() }
if (newHeight != topBarHeight) topBarHeight = newHeight
}
.headerHeightTracker { topBarHeight = it }
)
if (repositories.isEmpty()) {
Box(
@@ -319,9 +317,9 @@ class ManageScriptReposSection : Routes.Route() {
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 12.dp,
top = topBarHeight + 12.dp,
top = topBarHeight,
end = 12.dp,
bottom = 18.dp + routes.bottomPadding
bottom = routes.bottomPadding
),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {

View File

@@ -7,6 +7,7 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -22,12 +23,15 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.font.FontStyle
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.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.documentfile.provider.DocumentFile
@@ -38,6 +42,7 @@ import me.eternal.purrfectsnap.common.scripting.ui.InterfaceManager
import me.eternal.purrfectsnap.common.scripting.ui.ScriptInterface
import me.eternal.purrfectsnap.common.ui.AsyncUpdateDispatcher
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList
import me.eternal.purrfectsnap.common.ui.rememberAsyncUpdateDispatcher
import me.eternal.purrfectsnap.common.util.ktx.getUrlFromClipboard
import me.eternal.purrfectsnap.common.util.ktx.openLink
@@ -47,6 +52,8 @@ import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.components.AestheticEmptyState
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.util.Motion
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
import me.eternal.purrfectsnap.ui.util.Dialog
import me.eternal.purrfectsnap.ui.util.chooseFolder
@@ -59,7 +66,6 @@ class ScriptingRootSection : Routes.Route() {
override val translation by lazy { context.translation.getCategory("manager.scripting") }
private lateinit var activityLauncherHelper: ActivityLauncherHelper
val reloadDispatcher = AsyncUpdateDispatcher(updateOnFirstComposition = false)
private var selectedTab by mutableStateOf(0)
override val init: () -> Unit = {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
@@ -140,7 +146,7 @@ class ScriptingRootSection : Routes.Route() {
return@launch
}.onFailure {
context.log.error("Failed to import script", it)
context.shortToast(translation.format("import_failed", "message" to (it.message ?: context.translation["common.unknown"])))
context.shortToast(translation.format("import_failed", "message" to (it.message ?: "Unknown")))
}
isLoading = false
}
@@ -431,7 +437,7 @@ class ScriptingRootSection : Routes.Route() {
@Composable
private fun SelectFolderButton(onClick: () -> Unit) {
val label = translation["select_folder_button"]
val label = translation.getOrNull("select_folder_button") ?: "Select folder"
Box(
modifier = Modifier
.fillMaxWidth()
@@ -439,13 +445,13 @@ class ScriptingRootSection : Routes.Route() {
contentAlignment = Alignment.Center
) {
Surface(
modifier = Modifier.size(78.dp),
modifier = Modifier.size(68.dp),
shape = CircleShape,
color = PurrfectPalette.cardOverlayColor.copy(alpha = 0.9f),
color = Color.White.copy(alpha = 0.1f),
tonalElevation = 0.dp,
shadowElevation = 14.dp,
shadowElevation = 10.dp,
border = BorderStroke(
1.5.dp,
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.7f),
@@ -456,18 +462,17 @@ class ScriptingRootSection : Routes.Route() {
) {
Box(
modifier = Modifier
.padding(6.dp)
.size(66.dp)
.fillMaxSize()
.clip(CircleShape)
.background(
Brush.radialGradient(
colors = listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.42f),
PurrfectPalette.glowSecondary.copy(alpha = 0.34f)
PurrfectPalette.glowPrimary.copy(alpha = 0.335f),
PurrfectPalette.glowSecondary.copy(alpha = 0.25f),
Color.Transparent
)
)
)
.border(1.dp, Color.White.copy(alpha = 0.14f), CircleShape)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
@@ -475,7 +480,7 @@ class ScriptingRootSection : Routes.Route() {
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = label,
tint = Color.White,
modifier = Modifier.size(34.dp)
modifier = Modifier.size(32.dp)
)
}
}
@@ -500,6 +505,7 @@ class ScriptingRootSection : Routes.Route() {
}
}
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
val scriptingFolder by rememberAsyncMutableState(
defaultValue = null,
@@ -508,10 +514,14 @@ class ScriptingRootSection : Routes.Route() {
val tabTitles = listOf(translation["installed_scripts_tab"], translation["catalog_tab"])
var showImportDialog by remember { mutableStateOf(false) }
var showToast by remember { mutableStateOf(false) }
val density = androidx.compose.ui.platform.LocalDensity.current
var controlsHeight by remember { mutableStateOf(100.dp) }
val coroutineScope = rememberCoroutineScope()
val pagerState = androidx.compose.foundation.pager.rememberPagerState { tabTitles.size }
LaunchedEffect(scriptingFolder) {
if (scriptingFolder == null && selectedTab != 0) {
selectedTab = 0
if (scriptingFolder == null && pagerState.currentPage != 0) {
pagerState.scrollToPage(0)
}
}
@@ -532,12 +542,12 @@ class ScriptingRootSection : Routes.Route() {
) {
ScriptingHeader(
titles = tabTitles,
selectedTab = selectedTab,
selectedTab = pagerState.currentPage,
onTabSelected = { index ->
if (index == 1 && scriptingFolder == null) {
showToast = true
} else {
selectedTab = index
coroutineScope.launch { pagerState.animateScrollToPage(index) }
}
},
onImport = {
@@ -562,15 +572,99 @@ class ScriptingRootSection : Routes.Route() {
context.translation["toast_open_link_failed"]
)
},
folderSelected = scriptingFolder != null
folderSelected = scriptingFolder != null,
onPositioned = { controlsHeight = it }
)
Spacer(Modifier.height(12.dp))
when (selectedTab) {
0 -> InstalledTabContent(
scriptingFolder = scriptingFolder
)
1 -> CatalogTabContent(
scriptingFolder = scriptingFolder
androidx.compose.foundation.pager.HorizontalPager(
modifier = Modifier.fillMaxSize(),
state = pagerState,
userScrollEnabled = scriptingFolder != null
) { page ->
when (page) {
0 -> InstalledTabContent(
scriptingFolder = scriptingFolder,
controlsHeight = controlsHeight
)
1 -> CatalogTabContent(
scriptingFolder = scriptingFolder,
controlsHeight = controlsHeight
)
}
}
var scriptingWarning by remember {
mutableStateOf<Boolean>(context.sharedPreferences.run {
getBoolean("scripting_warning", true).also {
if (it) edit().putBoolean("scripting_warning", false).apply()
}
})
}
if (scriptingWarning) {
var timeout by remember { mutableIntStateOf(10) }
LaunchedEffect(Unit) {
while (timeout > 0) {
delay(1000)
timeout--
}
}
AestheticDialog(
onDismissRequest = { if (timeout == 0) scriptingWarning = false },
title = context.translation["manager.dialogs.scripting_warning.title"] ?: "Scripting Warning",
text = context.translation["manager.dialogs.scripting_warning.content"] ?: "Scripts can execute arbitrary code on your device. Only install scripts from trusted sources.",
icon = Icons.Default.Warning,
confirmButtonText = translation["button.ok"] ?: "OK",
onConfirm = { if (timeout == 0) scriptingWarning = false },
loading = timeout > 0,
showCloseButton = false,
customContent = {
Surface(
shape = RoundedCornerShape(14.dp),
color = PurrfectPalette.cardOverlayColor,
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Box(
modifier = Modifier
.size(64.dp)
.background(
Brush.radialGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.25f),
PurrfectPalette.glowSecondary.copy(alpha = 0.18f)
)
),
shape = RoundedCornerShape(18.dp)
),
contentAlignment = Alignment.Center
) {
Text(
text = timeout.toString(),
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 20.sp
)
}
}
}
}
)
}
}
@@ -578,7 +672,8 @@ class ScriptingRootSection : Routes.Route() {
@Composable
private fun InstalledTabContent(
scriptingFolder: DocumentFile?
scriptingFolder: DocumentFile?,
controlsHeight: androidx.compose.ui.unit.Dp
) {
val scriptModules by rememberAsyncMutableState(
defaultValue = emptyList(),
@@ -605,229 +700,144 @@ class ScriptingRootSection : Routes.Route() {
Box(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 12.dp)
) {
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.08f))
val listState = rememberLazyListState()
LaunchedEffect(listState.firstVisibleItemScrollOffset, listState.firstVisibleItemIndex) {
val offset = if (listState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else listState.firstVisibleItemScrollOffset
routes.navigation?.globalScrollOffset = offset
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
.pullRefresh(pullRefreshState),
state = listState,
contentPadding = PaddingValues(bottom = routes.bottomPadding, start = 8.dp, end = 8.dp, top = 12.dp),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.pullRefresh(pullRefreshState),
contentPadding = PaddingValues(bottom = routes.bottomPadding + 28.dp, start = 8.dp, end = 8.dp, top = 12.dp),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
if (scriptingFolder == null && !refreshing) {
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 260.dp),
contentAlignment = Alignment.Center
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
color = Color.White.copy(alpha = 0.05f),
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.6f),
PurrfectPalette.glowSecondary.copy(alpha = 0.45f)
)
)
item {
if (scriptingFolder == null && !refreshing) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(260.dp),
contentAlignment = Alignment.Center
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
color = Color.White.copy(alpha = 0.05f),
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.6f),
PurrfectPalette.glowSecondary.copy(alpha = 0.45f)
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 22.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Box(
modifier = Modifier
.size(58.dp)
.clip(RoundedCornerShape(18.dp))
.background(Color.White.copy(alpha = 0.08f)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.FolderOpen,
contentDescription = null,
tint = PurrfectPalette.glowSecondary,
modifier = Modifier.size(28.dp)
)
}
Text(
text = translation["no_scripts_folder_selected_title"],
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.ExtraBold,
textAlign = TextAlign.Center,
color = Color.White
)
Text(
text = translation["select_scripts_folder_toast"],
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = PurrfectPalette.textSecondary,
lineHeight = 18.sp
)
SelectFolderButton(
onClick = {
activityLauncherHelper.chooseFolder {
context.config.root.scripting.moduleFolder.set(it)
context.config.writeConfig()
coroutineScope.launch { reloadDispatcher.dispatch() }
}
}
)
}
}
}
} else if (scriptModules.isEmpty()) {
Box(
)
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.height(220.dp),
contentAlignment = Alignment.Center
.padding(horizontal = 20.dp, vertical = 22.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
AestheticEmptyState(
icon = Icons.Default.DataObject,
title = translation["no_scripts_found_title"],
subtitle = translation["use_catalog_to_add_scripts"],
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 22.dp)
.size(58.dp)
.clip(RoundedCornerShape(18.dp))
.background(Color.White.copy(alpha = 0.08f)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.FolderOpen,
contentDescription = null,
tint = PurrfectPalette.glowSecondary,
modifier = Modifier.size(28.dp)
)
}
Text(
text = translation["no_scripts_folder_selected_title"] ?: "No scripts folder selected",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.ExtraBold,
textAlign = TextAlign.Center,
color = Color.White
)
Text(
text = translation["select_scripts_folder_toast"] ?: "Please select a folder to store scripts.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = PurrfectPalette.textSecondary,
lineHeight = 18.sp
)
SelectFolderButton(
onClick = {
activityLauncherHelper.chooseFolder {
context.config.root.scripting.moduleFolder.set(it)
context.config.writeConfig()
coroutineScope.launch { reloadDispatcher.dispatch() }
}
}
)
}
}
}
items(scriptModules.size, key = { scriptModules[it].hashCode() }) { index ->
ModuleItem(scriptModules[index])
}
}
PullRefreshIndicator(
refreshing = refreshing,
state = pullRefreshState,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 8.dp)
)
}
}
}
var scriptingWarning by remember {
mutableStateOf(context.sharedPreferences.run {
getBoolean("scripting_warning", true).also {
edit().putBoolean("scripting_warning", false).apply()
}
})
}
if (scriptingWarning) {
var timeout by remember { mutableIntStateOf(10) }
LaunchedEffect(Unit) {
while (timeout > 0) {
delay(1000)
timeout--
}
}
AestheticDialog(
onDismissRequest = { if (timeout == 0) scriptingWarning = false },
title = context.translation["manager.dialogs.scripting_warning.title"],
text = context.translation["manager.dialogs.scripting_warning.content"],
icon = Icons.Default.Warning,
confirmButtonText = translation["button.ok"],
onConfirm = { if (timeout == 0) scriptingWarning = false },
loading = timeout > 0,
showCloseButton = false,
customContent = {
Surface(
shape = RoundedCornerShape(14.dp),
color = PurrfectPalette.cardOverlayColor,
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
)
) {
Column(
} else if (scriptModules.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
.height(220.dp),
contentAlignment = Alignment.Center
) {
Box(
AestheticEmptyState(
icon = Icons.Default.DataObject,
title = translation["no_scripts_found_title"] ?: "No scripts found",
subtitle = translation["use_catalog_to_add_scripts"] ?: "Check the catalog to find and install scripts.",
modifier = Modifier
.size(64.dp)
.background(
Brush.radialGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.25f),
PurrfectPalette.glowSecondary.copy(alpha = 0.18f)
)
),
shape = RoundedCornerShape(18.dp)
),
contentAlignment = Alignment.Center
) {
Text(
text = timeout.toString(),
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 20.sp
)
}
.fillMaxWidth()
.padding(horizontal = 22.dp)
)
}
}
}
items(scriptModules.size, key = { scriptModules[it].hashCode() }) { index ->
ModuleItem(scriptModules[index])
}
}
PullRefreshIndicator(
refreshing = refreshing,
state = pullRefreshState,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 8.dp)
)
}
}
@Composable
private fun CatalogTabContent(
scriptingFolder: DocumentFile?
scriptingFolder: DocumentFile?,
controlsHeight: androidx.compose.ui.unit.Dp
) {
val coroutineScope = rememberCoroutineScope()
Box(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 12.dp)
) {
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.08f))
) {
if (scriptingFolder == null) {
AestheticEmptyState(
icon = Icons.Default.FolderOpen,
title = translation["no_scripts_folder_selected_title"],
subtitle = translation["select_scripts_folder_toast"],
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 22.dp)
)
} else {
ScriptCatalog(this@ScriptingRootSection)
}
if (scriptingFolder == null) {
AestheticEmptyState(
icon = Icons.Default.FolderOpen,
title = translation["no_scripts_folder_selected_title"] ?: "No scripts folder selected",
subtitle = translation["select_scripts_folder_toast"] ?: "Please select a folder to store scripts.",
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 22.dp)
)
} else {
ScriptCatalog(this@ScriptingRootSection)
}
}
}
@@ -841,71 +851,51 @@ class ScriptingRootSection : Routes.Route() {
onOpenFolder: () -> Unit,
onManageRepos: () -> Unit,
onDocs: () -> Unit,
folderSelected: Boolean
folderSelected: Boolean,
onPositioned: (androidx.compose.ui.unit.Dp) -> Unit = {}
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp)
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()),
shape = RoundedCornerShape(26.dp),
color = Color.White.copy(alpha = 0.07f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
)
) {
Column(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = translation["manager.routes.scripts"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
)
val scrollOffset = routes.navigation?.globalScrollOffset ?: 0
val shrinkThreshold = 300f
val focusFactor = (scrollOffset / shrinkThreshold).coerceIn(0f, 1f)
val tabSwitcherAlpha = (1f - (focusFactor * 2.5f)).coerceIn(0f, 1f)
Column(modifier = Modifier.headerHeightTracker(onPositioned), verticalArrangement = Arrangement.spacedBy(12.dp)) {
me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar(
title = translation["manager.routes.scripts"] ?: "Scripts",
subtitle = if (selectedTab == 0) translation["installed_scripts_tab"] else translation["catalog_tab"],
scrollOffset = scrollOffset,
actions = {
IconButton(onClick = onDocs) {
Icon(Icons.Default.CollectionsBookmark, contentDescription = translation["documentation_button"], tint = Color.White)
}
Row(
modifier = Modifier.wrapContentWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onDocs) {
Icon(Icons.Default.CollectionsBookmark, contentDescription = translation["documentation_button"], tint = Color.White)
}
IconButton(onClick = onManageRepos) {
Icon(Icons.Default.Public, contentDescription = translation["manage_repos_button"], tint = Color.White)
}
IconButton(onClick = onOpenFolder) {
Icon(Icons.Default.FolderOpen, contentDescription = translation["open_scripts_folder_button"], tint = Color.White)
}
IconButton(onClick = onImport, enabled = folderSelected) {
Icon(Icons.Default.Link, contentDescription = translation["import_from_url_button"], tint = if (folderSelected) Color.White else Color.White.copy(alpha = 0.4f))
}
IconButton(onClick = onManageRepos) {
Icon(Icons.Default.Public, contentDescription = translation["manage_repos_button"], tint = Color.White)
}
IconButton(onClick = onOpenFolder) {
Icon(Icons.Default.FolderOpen, contentDescription = translation["open_scripts_folder_button"], tint = Color.White)
}
IconButton(onClick = onImport, enabled = folderSelected) {
Icon(Icons.Default.Link, contentDescription = translation["import_from_url_button"], tint = if (folderSelected) Color.White else Color.White.copy(alpha = 0.4f))
}
}
ScriptingTabSwitcher(
titles = titles,
selectedTab = selectedTab,
onTabSelected = onTabSelected
)
)
if (tabSwitcherAlpha > 0.05f) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp)
.graphicsLayer {
alpha = tabSwitcherAlpha
translationY = (-10 * focusFactor).dp.toPx()
}
) {
ScriptingTabSwitcher(
titles = titles,
selectedTab = selectedTab,
onTabSelected = onTabSelected
)
}
}
}
}
@@ -956,5 +946,5 @@ class ScriptingRootSection : Routes.Route() {
}
}
override val topBarActions: @Composable() (RowScope.() -> Unit) = {}
override val topBarActions: @Composable RowScope.() -> Unit = {}
}

View File

@@ -21,7 +21,6 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -47,6 +46,7 @@ import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.util.AlertDialogs
import me.eternal.purrfectsnap.ui.util.Dialog
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
@@ -118,16 +118,13 @@ class ManageScope: Routes.Route() {
},
modifier = Modifier
.zIndex(2f)
.onGloballyPositioned {
val newHeight = with(density) { it.size.height.toDp() }
if (newHeight != topBarHeight) topBarHeight = newHeight
}
.headerHeightTracker { topBarHeight = it }
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(top = topBarHeight + 8.dp)
.padding(top = topBarHeight)
.verticalScroll(rememberScrollState())
) {
var bottomComposable by remember {

View File

@@ -31,7 +31,6 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
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.TextOverflow
@@ -59,6 +58,7 @@ import me.eternal.purrfectsnap.storage.getGroupInfo
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.util.Dialog
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
@@ -767,16 +767,13 @@ class MessagingPreview: Routes.Route() {
},
modifier = Modifier
.zIndex(2f)
.onGloballyPositioned {
val newHeight = with(density) { it.size.height.toDp() }
if (newHeight != topBarHeight) topBarHeight = newHeight
}
.headerHeightTracker { topBarHeight = it }
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(top = topBarHeight + 6.dp)
.padding(top = topBarHeight)
.padding(horizontal = 14.dp)
) {
if (hasBridgeError) {

View File

@@ -7,6 +7,7 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -27,11 +28,15 @@ import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.util.Motion
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
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.sp
import androidx.navigation.NavBackStackEntry
@@ -62,7 +67,8 @@ class SocialRootSection : Routes.Route() {
private fun ScopeList(
scope: SocialScope,
friends: List<MessagingFriendInfo>,
groups: List<MessagingGroupInfo>
groups: List<MessagingGroupInfo>,
controlsHeight: androidx.compose.ui.unit.Dp
) {
val remainingHours = remember { context.config.root.streaksReminder.remainingHours.get() }
val list = when (scope) {
@@ -70,10 +76,17 @@ class SocialRootSection : Routes.Route() {
SocialScope.FRIEND -> friends
}
val listState = rememberLazyListState()
LaunchedEffect(listState.firstVisibleItemScrollOffset, listState.firstVisibleItemIndex) {
val offset = if (listState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else listState.firstVisibleItemScrollOffset
routes.navigation?.globalScrollOffset = offset
}
LazyColumn(
modifier = Modifier
.fillMaxSize(),
contentPadding = PaddingValues(start = 10.dp, end = 10.dp, bottom = routes.bottomPadding + 12.dp),
state = listState,
contentPadding = PaddingValues(start = 10.dp, end = 10.dp, top = controlsHeight, bottom = routes.bottomPadding),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
//check if scope list is empty
@@ -194,6 +207,8 @@ class SocialRootSection : Routes.Route() {
val pagerState = rememberPagerState { titles.size }
var searchQuery by rememberSaveable { mutableStateOf("") }
var searchActive by rememberSaveable { mutableStateOf(false) }
val density = androidx.compose.ui.platform.LocalDensity.current
var controlsHeight by remember { mutableStateOf(100.dp) }
LaunchedEffect(Unit) {
updateScopeLists()
@@ -234,10 +249,11 @@ class SocialRootSection : Routes.Route() {
onSearchToggle = {
searchActive = !searchActive
if (!searchActive) searchQuery = ""
}
},
onPositioned = { controlsHeight = it }
)
if (searchActive) {
val searchHint = context.translation["manager.dialogs.add_friend.search_hint"]
val searchHint = context.translation["manager.dialogs.add_friend.search_hint"] ?: "Search"
val searchShape = RoundedCornerShape(18.dp)
val searchBorder = Brush.linearGradient(
listOf(
@@ -307,8 +323,8 @@ class SocialRootSection : Routes.Route() {
state = pagerState
) { page ->
when (page) {
0 -> ScopeList(SocialScope.FRIEND, filteredFriends, filteredGroups)
1 -> ScopeList(SocialScope.GROUP, filteredFriends, filteredGroups)
0 -> ScopeList(SocialScope.FRIEND, filteredFriends, filteredGroups, controlsHeight = controlsHeight)
1 -> ScopeList(SocialScope.GROUP, filteredFriends, filteredGroups, controlsHeight = controlsHeight)
}
}
}
@@ -375,7 +391,7 @@ class SocialRootSection : Routes.Route() {
fontSize = 15.sp
)
Text(
text = translation["groups_tab"],
text = translation["groups_tab"] ?: "Groups",
color = PurrfectPalette.textSecondary,
fontSize = 12.sp
)
@@ -485,68 +501,50 @@ class SocialRootSection : Routes.Route() {
friendCount: Int,
groupCount: Int,
searchActive: Boolean,
onSearchToggle: () -> Unit
onSearchToggle: () -> Unit,
onPositioned: (Dp) -> Unit = {}
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp)
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()),
shape = RoundedCornerShape(26.dp),
color = Color.White.copy(alpha = 0.07f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
)
val scrollOffset = routes.navigation?.globalScrollOffset ?: 0
val focusFactor = (scrollOffset / Motion.HEADER_MORPH_THRESHOLD).coerceIn(0f, 1f)
val tabSwitcherAlpha = (1f - (focusFactor * 2.5f)).coerceIn(0f, 1f)
Column(
modifier = Modifier.headerHeightTracker(onPositioned),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Column(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = translation["manager.routes.social"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar(
title = translation["manager.routes.social"] ?: "Social",
subtitle = if (pagerState.currentPage == 0) translation["friends_tab"] else translation["groups_tab"],
scrollOffset = scrollOffset,
actions = {
StatPill(label = translation["friends_tab"], value = friendCount)
StatPill(label = translation["groups_tab"], value = groupCount)
IconButton(onClick = onSearchToggle) {
Icon(
imageVector = if (searchActive) Icons.Filled.Close else Icons.Filled.Search,
contentDescription = if (searchActive) translation["close_search_button_description"] else translation["search_button_description"],
tint = Color.White
)
}
Row(
modifier = Modifier.wrapContentWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
StatPill(label = translation["friends_tab"], value = friendCount)
StatPill(label = translation["groups_tab"], value = groupCount)
IconButton(onClick = onSearchToggle) {
Icon(
imageVector = if (searchActive) Icons.Filled.Close else Icons.Filled.Search,
contentDescription = if (searchActive) translation["close_search_button_description"] else translation["search_button_description"],
tint = Color.White
)
}
}
}
SocialTabSwitcher(
titles = titles,
pagerState = pagerState,
onTabSelected = onTabSelected
)
)
if (tabSwitcherAlpha > 0.05f) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp)
.graphicsLayer {
alpha = tabSwitcherAlpha
translationY = (-10 * focusFactor).dp.toPx()
}
) {
SocialTabSwitcher(
titles = titles,
pagerState = pagerState,
onTabSelected = onTabSelected
)
}
}
}
}
@@ -631,7 +629,7 @@ class SocialRootSection : Routes.Route() {
fontSize = 15.sp
)
Text(
text = translation["social_empty_hint"],
text = translation["social_empty_hint"] ?: "Tap the + button to sync friends or groups.",
color = PurrfectPalette.textSecondary,
fontSize = 12.sp
)

View File

@@ -496,7 +496,7 @@ class EditRule : Routes.Route() {
.background(PurrfectPalette.backgroundGradient)
.padding(padding)
) {
val contentBottomPadding = routes.bottomPadding + 12.dp
val contentBottomPadding = routes.bottomPadding
Column(
modifier = Modifier
.fillMaxSize()

View File

@@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.RowScope
@@ -32,7 +33,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -50,6 +50,8 @@ import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.AestheticEmptyState
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.util.Motion
import okhttp3.OkHttpClient
import okhttp3.Request
@@ -69,7 +71,10 @@ class FriendTrackerCatalog : Routes.Route() {
override val translation by lazy { context.translation.getCategory("manager.friend_tracker_catalog") }
@Composable
private fun AvailableRulesTab(topBarHeight: Dp) {
private fun AvailableRulesTab(
controlsHeight: androidx.compose.ui.unit.Dp,
onScrollOffsetChanged: (Int) -> Unit
) {
val coroutineScope = rememberCoroutineScope()
val okHttpClient = remember { OkHttpClient() }
val gson = remember { context.gson }
@@ -78,7 +83,6 @@ class FriendTrackerCatalog : Routes.Route() {
var repoIndexes by remember { mutableStateOf<Map<String, FriendTrackerRepoManifest>>(emptyMap()) }
var isLoading by remember { mutableStateOf(false) }
// Ticks whenever a rule import happens so isImported values recompute
var importTick by remember { mutableStateOf(0) }
fun refreshIndexes() {
@@ -93,7 +97,7 @@ class FriendTrackerCatalog : Routes.Route() {
repos.forEach { repoRoot ->
val indexUrl = if (repoRoot.endsWith("/")) "${repoRoot}index.json" else "$repoRoot/index.json"
try {
val req = Request.Builder().url(indexUrl).build() // ktlint-disable indent_wrapped_argument
val req = Request.Builder().url(indexUrl).build()
okHttpClient.newCall(req).execute().use { response ->
if (response.isSuccessful) {
response.body?.charStream()?.let { reader ->
@@ -160,14 +164,14 @@ class FriendTrackerCatalog : Routes.Route() {
coroutineScope.launch(Dispatchers.IO) {
val rawUrl = if (repoUrl.endsWith("/")) repoUrl + entry.path else repoUrl + "/" + entry.path
try {
val req = Request.Builder().url(rawUrl).build() // ktlint-disable indent_wrapped_argument
val req = Request.Builder().url(rawUrl).build()
okHttpClient.newCall(req).execute().use { response ->
if (!response.isSuccessful) {
withContext(Dispatchers.Main) { context.shortToast(translation.format("download_failed", "code" to response.code.toString())) }
return@use
}
val content = response.body?.string()
if (content != null) { // ktlint-disable no-multi-spaces
if (content != null) {
withContext(Dispatchers.Main) {
routes.friendTrackerConfigJsonForImport = content
routes.friendTrackerConfigImport.navigate()
@@ -182,27 +186,21 @@ class FriendTrackerCatalog : Routes.Route() {
}
}
if (repositories.isEmpty() && !isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = translation["no_repos_added"],
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface
)
}
} else {
val listState = rememberLazyListState()
LaunchedEffect(listState.firstVisibleItemScrollOffset, listState.firstVisibleItemIndex) {
val offset = if (listState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else listState.firstVisibleItemScrollOffset
onScrollOffsetChanged(offset)
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
contentPadding = PaddingValues(
start = 8.dp,
top = topBarHeight + 12.dp,
top = controlsHeight,
end = 8.dp,
bottom = 8.dp + routes.bottomPadding
bottom = routes.bottomPadding
)
) {
item {
@@ -233,7 +231,6 @@ class FriendTrackerCatalog : Routes.Route() {
}
}
items(allRules) { (repoUrl, entry) ->
// Compute isImported using produceState so the suspend db call runs in a coroutine
val isImported by produceState(initialValue = false, key1 = entry.name, key2 = importTick) {
val exists = withContext(Dispatchers.IO) {
context.database.getTrackerRuleByName(entry.name) != null
@@ -327,35 +324,34 @@ class FriendTrackerCatalog : Routes.Route() {
}
}
}
}
}
override val content: @Composable (NavBackStackEntry) -> Unit = {
val density = LocalDensity.current
val statusBarTopPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
var topBarHeight by remember { mutableStateOf(statusBarTopPadding + 96.dp) }
var scrollOffset by remember { mutableIntStateOf(0) }
val density = androidx.compose.ui.platform.LocalDensity.current
var controlsHeight by remember { mutableStateOf(100.dp) }
Box(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
AvailableRulesTab(
controlsHeight = controlsHeight,
onScrollOffsetChanged = { scrollOffset = it }
)
FloatingTopBar(
title = translation["title"],
onBack = { routes.navController.popBackStack() },
scrollOffset = scrollOffset,
modifier = Modifier.headerHeightTracker { controlsHeight = it },
actions = {
IconButton(onClick = { routes.manageFriendTrackerRepos.navigate() }) {
Icon(Icons.Default.Public, contentDescription = translation["manage_repos_description"], tint = Color.White)
}
},
modifier = Modifier
.zIndex(2f)
.onGloballyPositioned {
val newHeight = with(density) { it.size.height.toDp() }
if (newHeight != topBarHeight) topBarHeight = newHeight
}
}
)
AvailableRulesTab(topBarHeight = topBarHeight)
}
}
}

View File

@@ -8,12 +8,12 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.AutoGraph
import androidx.compose.material.icons.filled.DeleteOutline
@@ -25,13 +25,7 @@ import androidx.compose.material.icons.filled.FileOpen
import androidx.compose.material.icons.filled.Store
import androidx.compose.ui.window.Dialog
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -53,12 +47,12 @@ import me.eternal.purrfectsnap.storage.*
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.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.util.Motion
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
import me.eternal.purrfectsnap.ui.util.coil.BitmojiImage
import me.eternal.purrfectsnap.ui.util.openFile
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
import me.eternal.purrfectsnap.ui.util.pagerTabIndicatorOffset
@OptIn(ExperimentalFoundationApi::class)
class FriendTrackerManagerRoot : Routes.Route() {
@@ -160,8 +154,13 @@ class FriendTrackerManagerRoot : Routes.Route() {
label: String,
icon: ImageVector,
onClick: () -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
scrollOffset: Int = 0
) {
val shrinkThreshold = 300f
val focusFactor = (scrollOffset / shrinkThreshold).coerceIn(0f, 1f)
val labelAlpha = (1f - (focusFactor * 2.5f)).coerceIn(0f, 1f)
val shape = RoundedCornerShape(18.dp)
val backgroundBrush = remember {
Brush.linearGradient(
@@ -185,123 +184,25 @@ class FriendTrackerManagerRoot : Routes.Route() {
.background(backgroundBrush, shape)
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
horizontalArrangement = Arrangement.spacedBy(if (labelAlpha > 0.05f) 8.dp else 0.dp)
) {
Icon(icon, contentDescription = label, tint = Color.White)
Text(label, color = Color.White, fontWeight = FontWeight.SemiBold)
Icon(icon, contentDescription = label, tint = Color.White, modifier = Modifier.size(20.dp))
if (labelAlpha > 0.05f) {
Text(
label,
color = Color.White.copy(alpha = labelAlpha),
fontWeight = FontWeight.SemiBold,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Clip
)
}
}
}
}
override val topBarActions: @Composable RowScope.() -> Unit = {
var showExportDialog by remember { mutableStateOf(false) }
var showSingleExportDialog by remember { mutableStateOf(false) }
var showImportDialog by remember { mutableStateOf(false) }
var showInvalidImportTypeDialog by remember { mutableStateOf(false) }
if (showExportDialog) {
AestheticDialog(
onDismissRequest = { showExportDialog = false },
title = translation["export_dialog_title"],
text = translation["export_logs_dialog_confirm_text"],
icon = Icons.Default.SaveAlt,
confirmButtonText = translation["export_button"],
onConfirm = {
showExportDialog = false
routes.friendTrackerConfigExport.navigate()
},
dismissButtonText = translation["button.cancel"],
onDismiss = { showExportDialog = false },
opaque = true,
showCloseButton = false
)
}
if (showSingleExportDialog) {
val rules = rememberAsyncMutableStateList(defaultValue = emptyList()) {
context.database.getTrackerRulesDesc()
}
SelectRuleDialog(
onDismissRequest = { showSingleExportDialog = false },
rules = rules,
onRuleSelected = { rule ->
showSingleExportDialog = false
routes.friendTrackerConfigExport.navigate {
this["rule_id"] = rule.id.toString()
}
},
translation = translation
)
}
fun handleImport(type: me.eternal.purrfectsnap.common.data.ExportType) {
routes.activityLauncher.openFile("application/json") { uri ->
runCatching {
val content = context.androidContext.contentResolver.openInputStream(android.net.Uri.parse(uri))?.use {
it.readBytes().toString(Charsets.UTF_8)
} ?: return@runCatching
val exportedData = context.gson.fromJson(content, me.eternal.purrfectsnap.common.data.ExportedTrackerData::class.java)
if (exportedData.type != type) {
showInvalidImportTypeDialog = true
return@runCatching
}
routes.friendTrackerConfigJsonForImport = content
routes.friendTrackerConfigImport.navigate()
}.onFailure {
context.longToast(
translation.format("read_file_failed_toast", "message" to (it.message ?: ""))
)
}
}
}
if (showInvalidImportTypeDialog) {
AlertDialog(
onDismissRequest = { showInvalidImportTypeDialog = false },
title = { Text(translation["invalid_import_type_dialog_title"]) },
text = { Text(translation["invalid_import_type_dialog_text"]) },
confirmButton = {
Button(onClick = { showInvalidImportTypeDialog = false }) {
Text(translation["button.ok"])
}
}
)
}
if (showImportDialog) {
AestheticDialog(
onDismissRequest = { showImportDialog = false },
title = translation["import_dialog_title"],
text = translation["import_dialog_subtitle"] ?: translation["import_dialog_title"],
icon = Icons.Default.FolderOpen,
confirmButtonText = translation["bulk_import_button"],
onConfirm = {
showImportDialog = false
handleImport(me.eternal.purrfectsnap.common.data.ExportType.BULK)
},
dismissButtonText = translation["individual_import_button"],
onDismiss = {
showImportDialog = false
handleImport(me.eternal.purrfectsnap.common.data.ExportType.SINGLE)
},
opaque = true,
showCloseButton = false
)
}
if (currentPage == 0) {
TrackerIconButton(
icon = Icons.Default.FolderOpen,
contentDescription = translation["import_button_description"],
onClick = { showImportDialog = true }
)
Spacer(Modifier.width(8.dp))
TrackerIconButton(
icon = Icons.Default.SaveAlt,
contentDescription = translation["export_button_description"],
onClick = { showExportDialog = true }
)
}
// Handled via FloatingTopBar
}
private lateinit var activityLauncherHelper: ActivityLauncherHelper
@@ -347,11 +248,18 @@ class FriendTrackerManagerRoot : Routes.Route() {
}
@Composable
private fun ConfigRulesTab() {
private fun ConfigRulesTab(scrollOffset: (Int) -> Unit) {
val updateRules = rememberAsyncUpdateDispatcher()
val rules = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = updateRules) {
context.database.getTrackerRulesDesc()
}
val listState = rememberLazyListState()
LaunchedEffect(listState.firstVisibleItemScrollOffset, listState.firstVisibleItemIndex) {
val offset = if (listState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else listState.firstVisibleItemScrollOffset
scrollOffset(offset)
}
@Composable
fun EmptyState(text: String) {
Column(
@@ -389,178 +297,175 @@ class FriendTrackerManagerRoot : Routes.Route() {
}
}
Column(
modifier = Modifier.fillMaxSize()
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
contentPadding = PaddingValues(bottom = routes.bottomPadding)
) {
LazyColumn(
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(bottom = routes.bottomPadding)
) {
item {
if (rules.isEmpty()) {
EmptyState(translation["no_rules_found"])
}
item {
if (rules.isEmpty()) {
EmptyState(translation["no_rules_found"])
}
}
items(rules, key = { it.id }) { rule ->
val ruleName by rememberAsyncMutableState(defaultValue = rule.name) {
context.database.getTrackerRule(rule.id)?.name ?: translation["empty_rule_name"]
}
val eventCount by rememberAsyncMutableState(defaultValue = 0) {
context.database.getTrackerEvents(rule.id).size
}
val scopeCount by rememberAsyncMutableState(defaultValue = 0) {
context.database.getRuleTrackerScopes(rule.id).size
}
var enabled by rememberAsyncMutableState(defaultValue = rule.enabled) {
context.database.getTrackerRule(rule.id)?.enabled ?: false
}
items(rules, key = { it.id }) { rule ->
val ruleName by rememberAsyncMutableState(defaultValue = rule.name) {
context.database.getTrackerRule(rule.id)?.name ?: translation["empty_rule_name"]
}
val eventCount by rememberAsyncMutableState(defaultValue = 0) {
context.database.getTrackerEvents(rule.id).size
}
val scopeCount by rememberAsyncMutableState(defaultValue = 0) {
context.database.getRuleTrackerScopes(rule.id).size
}
var enabled by rememberAsyncMutableState(defaultValue = rule.enabled) {
context.database.getTrackerRule(rule.id)?.enabled ?: false
}
val ruleShape = RoundedCornerShape(20.dp)
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable {
routes.editRule.navigate {
this["rule_id"] = rule.id.toString()
}
val ruleShape = RoundedCornerShape(20.dp)
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable {
routes.editRule.navigate {
this["rule_id"] = rule.id.toString()
}
.padding(8.dp),
shape = ruleShape,
color = Color.Transparent,
tonalElevation = 0.dp,
shadowElevation = 12.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.5f),
PurrfectPalette.glowSecondary.copy(alpha = 0.4f)
)
}
.padding(horizontal = 8.dp, vertical = 6.dp),
shape = ruleShape,
color = Color.Transparent,
tonalElevation = 0.dp,
shadowElevation = 12.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.5f),
PurrfectPalette.glowSecondary.copy(alpha = 0.4f)
)
)
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(PurrfectPalette.cardOverlay, ruleShape)
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(PurrfectPalette.cardOverlay, ruleShape)
.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),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f))
) {
Surface(
shape = CircleShape,
color = Color.White.copy(alpha = 0.08f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f))
) {
Box(
modifier = Modifier
.size(54.dp)
.background(
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.35f),
PurrfectPalette.glowSecondary.copy(alpha = 0.3f)
)
),
CircleShape
Box(
modifier = Modifier
.size(54.dp)
.background(
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.35f),
PurrfectPalette.glowSecondary.copy(alpha = 0.3f)
)
),
contentAlignment = Alignment.Center
) {
Icon(Icons.AutoMirrored.Filled.Rule, contentDescription = null, tint = Color.White)
}
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
CircleShape
),
contentAlignment = Alignment.Center
) {
Text(ruleName, fontSize = 18.sp, fontWeight = FontWeight.ExtraBold, color = Color.White)
Text(
buildString {
append(eventCount)
Icon(Icons.AutoMirrored.Filled.Rule, contentDescription = null, tint = Color.White)
}
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(ruleName, fontSize = 18.sp, fontWeight = FontWeight.ExtraBold, color = Color.White)
Text(
buildString {
append(eventCount)
append(" ")
append(translation["events_suffix"])
if (scopeCount > 0) {
append("")
append(scopeCount)
append(" ")
append(translation["events_suffix"])
if (scopeCount > 0) {
append("")
append(scopeCount)
append(" ")
append(translation["scopes_suffix"])
}
},
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = PurrfectPalette.textSecondary
)
if (scopeCount > 0) {
val scopesBitmoji = rememberAsyncMutableStateList(defaultValue = emptyList()) {
context.database.getRuleTrackerScopes(rule.id, limit = 8).mapNotNull {
context.database.getFriendInfo(it.key)?.let { friend ->
friend.selfieId to friend.bitmojiId
}
append(translation["scopes_suffix"])
}
},
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = PurrfectPalette.textSecondary
)
if (scopeCount > 0) {
val scopesBitmoji = rememberAsyncMutableStateList(defaultValue = emptyList()) {
context.database.getRuleTrackerScopes(rule.id, limit = 8).mapNotNull {
context.database.getFriendInfo(it.key)?.let { friend ->
friend.selfieId to friend.bitmojiId
}
}
Row(
horizontalArrangement = Arrangement.spacedBy((-10).dp),
verticalAlignment = Alignment.CenterVertically
) {
scopesBitmoji.take(4).forEach { friend ->
BitmojiImage(
size = 34,
}
Row(
horizontalArrangement = Arrangement.spacedBy((-10).dp),
verticalAlignment = Alignment.CenterVertically
) {
scopesBitmoji.take(4).forEach { friend ->
BitmojiImage(
size = 34,
modifier = Modifier
.border(BorderStroke(1.dp, Color.White), CircleShape)
.background(Color.White, CircleShape)
.clip(CircleShape),
context = context,
url = BitmojiSelfie.getBitmojiSelfie(friend.first, friend.second, BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D),
)
}
if (scopeCount > scopesBitmoji.size) {
Surface(
shape = CircleShape,
color = Color.White.copy(alpha = 0.08f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Text(
text = "+${scopeCount - scopesBitmoji.size}",
color = Color.White,
fontWeight = FontWeight.Bold,
modifier = Modifier
.border(BorderStroke(1.dp, Color.White), CircleShape)
.background(Color.White, CircleShape)
.clip(CircleShape),
context = context,
url = BitmojiSelfie.getBitmojiSelfie(friend.first, friend.second, BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D),
.padding(horizontal = 10.dp, vertical = 6.dp)
)
}
if (scopeCount > scopesBitmoji.size) {
Surface(
shape = CircleShape,
color = Color.White.copy(alpha = 0.08f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Text(
text = "+${scopeCount - scopesBitmoji.size}",
color = Color.White,
fontWeight = FontWeight.Bold,
modifier = Modifier
.padding(horizontal = 10.dp, vertical = 6.dp)
)
}
}
}
}
}
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(6.dp)
}
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Surface(
shape = RoundedCornerShape(12.dp),
color = Color.White.copy(alpha = 0.06f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f))
) {
Surface(
shape = RoundedCornerShape(12.dp),
color = Color.White.copy(alpha = 0.06f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f))
) {
Text(
text = translation[if (enabled) "enabled_label" else "disabled_label"],
color = Color.White,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)
)
}
Switch(
checked = enabled,
onCheckedChange = {
enabled = it
context.database.setTrackerRuleState(rule.id, it)
},
colors = purrfectSwitchColors()
Text(
text = translation[if (enabled) "enabled_label" else "disabled_label"],
color = Color.White,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)
)
}
Switch(
checked = enabled,
onCheckedChange = {
enabled = it
context.database.setTrackerRuleState(rule.id, it)
},
colors = purrfectSwitchColors()
)
}
}
}
@@ -574,10 +479,14 @@ class FriendTrackerManagerRoot : Routes.Route() {
val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState(initialPage = 0) { titles.size }
currentPage = pagerState.currentPage
var scrollOffset by remember { mutableIntStateOf(0) }
var showExportDialog by remember { mutableStateOf(false) }
var showSingleExportDialog by remember { mutableStateOf(false) }
var showImportDialog by remember { mutableStateOf(false) }
var showInvalidImportTypeDialog by remember { mutableStateOf(false) }
val density = androidx.compose.ui.platform.LocalDensity.current
var controlsHeight by remember { mutableStateOf(100.dp) }
fun handleImport(type: me.eternal.purrfectsnap.common.data.ExportType) {
routes.activityLauncher.openFile("application/json") { uri ->
@@ -606,125 +515,77 @@ class FriendTrackerManagerRoot : Routes.Route() {
.background(PurrfectPalette.backgroundGradient)
) {
Column(modifier = Modifier.fillMaxSize()) {
Surface(
me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar(
title = context.translation["manager.routes.friend_tracker"],
subtitle = titles.getOrNull(pagerState.currentPage) ?: "",
onBack = { routes.navController.popBackStack() },
scrollOffset = scrollOffset,
modifier = Modifier.headerHeightTracker { controlsHeight = it },
actions = {
if (pagerState.currentPage == 0) {
TrackerPillButton(
label = translation["import_button"],
icon = Icons.Default.FolderOpen,
scrollOffset = scrollOffset,
onClick = { showImportDialog = true }
)
TrackerPillButton(
label = translation["export_button"],
icon = Icons.Default.SaveAlt,
scrollOffset = scrollOffset,
onClick = { showExportDialog = true }
)
}
}
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp)
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()),
shape = RoundedCornerShape(26.dp),
color = PurrfectPalette.cardOverlayColor,
tonalElevation = 0.dp,
shadowElevation = 10.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
)
.padding(horizontal = 14.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
titles.forEachIndexed { i, text ->
val selected = pagerState.currentPage == i
Surface(
modifier = Modifier.weight(1f).clip(RoundedCornerShape(18.dp)).clickable {
coroutineScope.launch { pagerState.animateScrollToPage(i) }
},
shape = RoundedCornerShape(18.dp),
color = if (selected) Color.White.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.06f),
border = if (selected) BorderStroke(1.dp, Brush.linearGradient(listOf(PurrfectPalette.glowPrimary, PurrfectPalette.glowSecondary))) else BorderStroke(1.dp, Color.White.copy(alpha = 0.16f)),
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
horizontalArrangement = Arrangement.Center
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = context.translation["manager.routes.friend_tracker"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 20.sp
)
Text(
text = titles.getOrNull(pagerState.currentPage) ?: "",
color = PurrfectPalette.textSecondary,
fontSize = 14.sp
)
}
if (pagerState.currentPage == 0) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.End
) {
TrackerPillButton(
label = translation["import_button"],
icon = Icons.Default.FolderOpen,
onClick = { showImportDialog = true }
)
TrackerPillButton(
label = translation["export_button"],
icon = Icons.Default.SaveAlt,
onClick = { showExportDialog = true }
)
}
}
Text(text = text, color = Color.White, fontWeight = FontWeight.SemiBold)
}
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Surface(
HorizontalPager(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 12.dp),
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.08f))
) {
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
titles.forEachIndexed { i, text ->
val selected = pagerState.currentPage == i
Surface(
modifier = Modifier.weight(1f).clip(RoundedCornerShape(18.dp)).clickable {
coroutineScope.launch { pagerState.animateScrollToPage(i) }
},
shape = RoundedCornerShape(18.dp),
color = if (selected) Color.White.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.06f),
border = if (selected) BorderStroke(1.dp, Brush.linearGradient(listOf(PurrfectPalette.glowPrimary, PurrfectPalette.glowSecondary))) else BorderStroke(1.dp, Color.White.copy(alpha = 0.16f)),
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(text = text, color = Color.White, fontWeight = FontWeight.SemiBold)
}
}
}
}
HorizontalPager(
modifier = Modifier
.weight(1f)
.padding(horizontal = 4.dp, vertical = 4.dp),
state = pagerState
) { page ->
when (page) {
1 -> LogsTab(
context = context,
activityLauncherHelper = activityLauncherHelper,
deleteAction = { logDeleteAction = it },
exportAction = { exportAction = it },
bottomPadding = routes.bottomPadding
)
0 -> ConfigRulesTab()
}
}
.padding(horizontal = 4.dp, vertical = 4.dp),
state = pagerState
) { page ->
when (page) {
1 -> LogsTab(
context = context,
activityLauncherHelper = activityLauncherHelper,
deleteAction = { logDeleteAction = it },
exportAction = { exportAction = it },
bottomPadding = routes.bottomPadding,
scrollOffset = { scrollOffset = it }
)
0 -> ConfigRulesTab(scrollOffset = { scrollOffset = it })
}
}
}
@@ -735,8 +596,8 @@ class FriendTrackerManagerRoot : Routes.Route() {
onDismissRequest = { showExportDialog = false },
title = translation["export_dialog_title"],
choices = listOf(
translation["bulk_export_button"] to { Icon(Icons.Default.UploadFile, translation["bulk_export_button"]) },
translation["individual_export_button"] to { Icon(Icons.Default.FileOpen, translation["individual_export_button"]) }
translation["bulk_export_button"] to { Icon(Icons.Default.UploadFile, translation["bulk_export_button"], tint = Color.White) },
translation["individual_export_button"] to { Icon(Icons.Default.FileOpen, translation["individual_export_button"], tint = Color.White) }
),
onChoiceSelected = { index ->
showExportDialog = false
@@ -783,8 +644,8 @@ class FriendTrackerManagerRoot : Routes.Route() {
onDismissRequest = { showImportDialog = false },
title = translation["import_dialog_title"],
choices = listOf(
translation["bulk_import_button"] to { Icon(Icons.Default.UploadFile, translation["bulk_import_button"]) },
translation["individual_import_button"] to { Icon(Icons.Default.FileOpen, translation["individual_import_button"]) }
translation["bulk_import_button"] to { Icon(Icons.Default.UploadFile, translation["bulk_import_button"], tint = Color.White) },
translation["individual_import_button"] to { Icon(Icons.Default.FileOpen, translation["individual_import_button"], tint = Color.White) }
),
onChoiceSelected = { index ->
showImportDialog = false
@@ -806,33 +667,51 @@ private fun SelectRuleDialog(
translation: me.eternal.purrfectsnap.common.bridge.wrapper.LocaleWrapper
) {
Dialog(onDismissRequest = onDismissRequest) {
Card(
shape = RoundedCornerShape(16.dp),
val shape = RoundedCornerShape(24.dp)
Surface(
shape = shape,
color = Color.Transparent,
tonalElevation = 0.dp,
shadowElevation = 20.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Column(
modifier = Modifier.padding(16.dp),
modifier = Modifier
.background(PurrfectPalette.cardOverlay, shape)
.padding(18.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(translation["manager.friend_tracker.select_rule_to_export_title"], style = MaterialTheme.typography.headlineSmall)
Text(
translation["manager.friend_tracker.select_rule_to_export_title"],
style = MaterialTheme.typography.headlineSmall,
color = Color.White,
fontWeight = FontWeight.ExtraBold,
textAlign = TextAlign.Center
)
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp)
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.heightIn(max = 300.dp)
) {
items(rules) { rule ->
ElevatedCard(
Surface(
onClick = { onRuleSelected(rule) },
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
color = Color.White.copy(alpha = 0.06f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f))
) {
Text(
text = rule.name,
modifier = Modifier.padding(16.dp),
fontWeight = FontWeight.SemiBold
fontWeight = FontWeight.SemiBold,
color = Color.White
)
}
}
}
TextButton(onClick = onDismissRequest) {
Text(translation["button.cancel"])
Text(translation["button.cancel"], color = PurrfectPalette.glowSecondary)
}
}
}
@@ -847,7 +726,7 @@ private fun ChoiceDialog(
onChoiceSelected: (Int) -> Unit
) {
Dialog(onDismissRequest = onDismissRequest) {
val shape = RoundedCornerShape(20.dp)
val shape = RoundedCornerShape(24.dp)
Surface(
shape = shape,
color = Color.Transparent,
@@ -865,9 +744,9 @@ private fun ChoiceDialog(
Column(
modifier = Modifier
.background(PurrfectPalette.cardOverlay, shape)
.padding(horizontal = 18.dp, vertical = 16.dp),
.padding(horizontal = 20.dp, vertical = 22.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Text(
text = title,
@@ -876,36 +755,24 @@ private fun ChoiceDialog(
textAlign = TextAlign.Center
)
choices.forEachIndexed { index, (text, icon) ->
SelectButton(
Surface(
onClick = { onChoiceSelected(index) },
text = text,
leadingIcon = icon
)
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(18.dp),
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
icon()
Text(text = text, modifier = Modifier.weight(1f), color = Color.White, fontWeight = FontWeight.SemiBold)
}
}
}
}
}
}
}
@Composable
private fun SelectButton(
onClick: () -> Unit,
text: String,
leadingIcon: @Composable (() -> Unit)? = null,
) {
OutlinedButton(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
if (leadingIcon != null) {
leadingIcon()
}
Text(text = text, modifier = Modifier.weight(1f))
}
}
}

View File

@@ -1,5 +1,7 @@
package me.eternal.purrfectsnap.ui.manager.pages.tracker
import me.eternal.purrfectsnap.ui.util.Motion
import android.net.Uri
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
@@ -7,6 +9,7 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -61,6 +64,7 @@ fun LogsTab(
deleteAction: (() -> Unit) -> Unit,
exportAction: (() -> Unit) -> Unit,
bottomPadding: Dp,
scrollOffset: (Int) -> Unit
) {
val translation = remember { context.translation.getCategory("manager.friend_tracker") }
val trackerTranslation = remember { context.translation.getCategory("tracker") }
@@ -78,6 +82,12 @@ fun LogsTab(
var filter by remember { mutableStateOf("") }
var searchTimeoutJob by remember { mutableStateOf<Job?>(null) }
val listState = rememberLazyListState()
LaunchedEffect(listState.firstVisibleItemScrollOffset, listState.firstVisibleItemIndex) {
val offset = if (listState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else listState.firstVisibleItemScrollOffset
scrollOffset(offset)
}
fun getPaginatedLogs(pageIndex: Int) = context.messageLogger.getLogs(
pageIndex = pageIndex,
pageSize = 30,
@@ -665,13 +675,15 @@ fun LogsTab(
)
}
}
}
}
LazyColumn(
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(bottom = bottomPadding)
) {
}
}
LazyColumn(
modifier = Modifier.weight(1f),
state = listState,
contentPadding = PaddingValues(bottom = bottomPadding)
) {
item {
Row(
modifier = Modifier

View File

@@ -73,6 +73,7 @@ import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.components.AestheticEmptyState
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import okhttp3.OkHttpClient
class ManageFriendTrackerReposSection: Routes.Route() {
@@ -286,10 +287,7 @@ class ManageFriendTrackerReposSection: Routes.Route() {
onBack = { routes.navController.popBackStack() },
modifier = Modifier
.zIndex(2f)
.onGloballyPositioned {
val newHeight = with(density) { it.size.height.toDp() }
if (newHeight != topBarHeight) topBarHeight = newHeight
}
.headerHeightTracker { topBarHeight = it }
)
if (repositories.isEmpty()) {
Box(
@@ -309,9 +307,9 @@ class ManageFriendTrackerReposSection: Routes.Route() {
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 12.dp,
top = topBarHeight + 12.dp,
top = topBarHeight,
end = 12.dp,
bottom = 18.dp + routes.bottomPadding
bottom = routes.bottomPadding
),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {

View File

@@ -68,7 +68,7 @@ class RemoteOverlay(
containerColor = Color.Transparent,
topBar = { navigation.TopBar() }
) { innerPadding ->
navigation.NavContent(
navigation.Content(
innerPadding,
startDestination = remember { startRoute(navigation.routes).routeInfo.id }
)

View File

@@ -1,5 +1,6 @@
package me.eternal.purrfectsnap.ui.setup.screens.impl
import android.net.Uri
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -104,7 +105,14 @@ class SaveFolderScreen : SetupScreen() {
color = PurrfectPalette.textSecondary
)
Text(
text = if (currentFolder.isBlank()) context.translation["setup.save_folder.system_default_label"] else currentFolder,
text = if (currentFolder.isBlank()) {
context.translation["setup.save_folder.system_default_label"]
} else {
// Decode and simplify the URI for professional display
runCatching {
Uri.decode(currentFolder).substringAfterLast("%3A", currentFolder).substringAfterLast(":")
}.getOrDefault(currentFolder)
},
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White,