Revert "Feature: UI Modernization, Themed Icons, and Performance Optimization"

This commit is contained in:
ΞTΞRNAL
2026-03-04 14:15:32 +05:30
committed by GitHub
parent 5efccc9e78
commit ec9ecac15c
56 changed files with 3704 additions and 3226 deletions

View File

@@ -86,7 +86,7 @@ class AnnouncementCheckWorker(
val pendingIntent = PendingIntent.getActivity(appContext, 0, intent, PendingIntent.FLAG_IMMUTABLE)
val builder = NotificationCompat.Builder(appContext, channelId)
.setSmallIcon(R.mipmap.ic_launcher_monochrome)
.setSmallIcon(R.drawable.launcher_icon_monochrome)
.setContentTitle(title)
.setContentText(text)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)

View File

@@ -65,7 +65,7 @@ class UpdateCheckWorker(
val pendingIntent: PendingIntent = PendingIntent.getActivity(appContext, 0, intent, PendingIntent.FLAG_IMMUTABLE)
val builder = NotificationCompat.Builder(appContext, channelId)
.setSmallIcon(R.mipmap.ic_launcher_monochrome)
.setSmallIcon(R.drawable.launcher_icon_monochrome)
.setContentTitle(title)
.setContentText(text.format(versionName))
.setPriority(NotificationCompat.PRIORITY_DEFAULT)

View File

@@ -132,7 +132,7 @@ class MainActivity : ComponentActivity() {
if (shouldShowAbiWarning) {
AestheticDialog(
onDismissRequest = {},
title = managerContext.translation["setup.activity.wrong_apk_title"],
title = managerContext.translation["wrong_apk_title"],
text = "",
icon = Icons.Filled.Warning,
confirmButtonText = managerContext.translation["common.close"],
@@ -140,20 +140,11 @@ class MainActivity : ComponentActivity() {
showCloseButton = false,
opaque = true,
customContent = {
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)
)
}
Text(
text = managerContext.translation["wrong_apk_message"],
color = PurrfectPalette.textSecondary,
lineHeight = 18.sp
)
}
)
}
@@ -172,7 +163,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 = 82.dp + 4.dp +
val bottomPadding = 80.dp + 16.dp +
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
routes.bottomPadding = bottomPadding
val navBackStackEntry by navController.currentBackStackEntryAsState()

View File

@@ -33,7 +33,6 @@ 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
@@ -73,7 +72,6 @@ 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
@@ -124,37 +122,22 @@ 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 = me.eternal.purrfectsnap.ui.util.Motion.HEADER_MORPH_THRESHOLD
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
}
val haptic = LocalHapticFeedback.current
TopAppBar(
modifier = Modifier.height(headerHeight),
title = {
currentRoute?.apply {
title?.invoke() ?: routeInfo.translatedKey?.value?.let {
Text(
text = it,
maxLines = 1,
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()
}
overflow = TextOverflow.Ellipsis
)
}
}
@@ -163,20 +146,11 @@ class Navigation(
val backButtonAnimation by animateFloatAsState(if (canGoBack) 1f else 0f, label = "backButton")
Box(
modifier = Modifier
.graphicsLayer {
alpha = backButtonAnimation
scaleX = 1f - (focusFactor * 0.1f)
scaleY = 1f - (focusFactor * 0.1f)
}
.graphicsLayer { alpha = backButtonAnimation }
.width(lerp(0.dp, 48.dp, backButtonAnimation))
.height(48.dp)
) {
IconButton(onClick = {
if (canGoBack) {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
navController.popBackStack()
}
}) {
IconButton(onClick = { if (canGoBack) navController.popBackStack() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
}
@@ -188,10 +162,7 @@ class Navigation(
actions = {
currentRoute?.topBarActions?.invoke(this)
if (currentRoute?.routeInfo?.id == routes.settings.routeInfo.id) {
IconButton(onClick = {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
openBottomBarCustomization = true
}) {
IconButton(onClick = { openBottomBarCustomization = true }) {
Icon(Icons.Filled.Tune, contentDescription = null)
}
}
@@ -200,20 +171,12 @@ class Navigation(
}
@Composable
fun FloatingBottomBar() {
val haptic = LocalHapticFeedback.current
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = remember(navBackStackEntry) { routes.getCurrentRoute(navBackStackEntry) }
val availableRoutes = remember {
listOf(routes.tasks, routes.features, routes.home, routes.social, routes.scripting, routes.friendTracker)
}
val availableRouteMap = remember(availableRoutes) { availableRoutes.associateBy { it.routeInfo.id } }
val shrinkThreshold = me.eternal.purrfectsnap.ui.util.Motion.HEADER_MORPH_THRESHOLD
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> {
@@ -270,20 +233,11 @@ class Navigation(
val animatedBarWidth by animateDpAsState(targetValue = targetBarWidth ?: 0.dp, label = "barWidth")
Surface(
shape = barShape,
color = Color.White.copy(alpha = 0.08f), // Apply translucent overlay for depth
color = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onSurface,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.9f),
PurrfectPalette.glowSecondary.copy(alpha = 0.85f)
)
)
),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)),
modifier = Modifier
.then(if (targetBarWidth != null) Modifier.width(animatedBarWidth) else Modifier.fillMaxWidth())
.height(barHeight)
.drawBehind {
val radius = size.width * 0.62f
drawCircle(
@@ -309,7 +263,7 @@ class Navigation(
Box(
Modifier
.fillMaxWidth()
.height(barHeight)
.height(82.dp)
.clip(barShape)
.background(PurrfectPalette.cardOverlay)
.border(BorderStroke(1.dp, barBorder), barShape)
@@ -342,7 +296,7 @@ class Navigation(
)
}
)
Box(Modifier.fillMaxWidth().height(barHeight)) {
Box(Modifier.fillMaxWidth().height(82.dp)) {
var barWidthPx by remember { mutableStateOf(0f) }
val itemCount = selectedRoutes.size.coerceAtLeast(1)
val density = androidx.compose.ui.platform.LocalDensity.current
@@ -415,7 +369,7 @@ class Navigation(
.fillMaxHeight()
.width(indicatorWidth.coerceAtLeast(0.dp))
.offset(x = offsetX)
.padding(vertical = lerp(10.dp, 8.dp, focusFactor), horizontal = 2.dp)
.padding(vertical = 10.dp, horizontal = 2.dp)
.graphicsLayer { scaleX = scaleXAnim; scaleY = scaleYAnim }
) {
Box(
@@ -476,10 +430,7 @@ class Navigation(
contentDescription = null,
modifier = Modifier
.size(22.dp + 2.dp * selectionProgress)
.graphicsLayer {
alpha = 0.65f + 0.35f * selectionProgress
translationY = iconTranslationY.toPx()
}
.graphicsLayer { alpha = 0.65f + 0.35f * selectionProgress }
)
},
label = {
@@ -490,15 +441,11 @@ class Navigation(
textAlign = TextAlign.Center,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White.copy(alpha = (0.6f + 0.4f * selectionProgress) * labelAlpha),
color = Color.White.copy(alpha = 0.6f + 0.4f * selectionProgress),
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))
.graphicsLayer {
alpha = labelAlpha
translationY = (-10 * focusFactor).dp.toPx() // Fall into icon
}
modifier = if (isLong) Modifier.widthIn(max = 90.dp).wrapContentWidth(Alignment.CenterHorizontally) else Modifier.wrapContentWidth(Alignment.CenterHorizontally)
)
},
selected = isSelected,
@@ -509,10 +456,7 @@ class Navigation(
unselectedTextColor = Color.White.copy(alpha = 0.72f),
indicatorColor = Color.Transparent
),
onClick = {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
route.navigateReset()
}
onClick = { route.navigateReset() }
)
}
}
@@ -861,4 +805,3 @@ class Navigation(
@Composable fun FloatingActionButton() = Fab()
@Composable fun Content(paddingValues: PaddingValues, startDestination: String) = NavContent(paddingValues, startDestination)
}

View File

@@ -1,284 +1,125 @@
package me.eternal.purrfectsnap.ui.manager.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
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.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.lerp
import androidx.compose.ui.zIndex
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.PurrfectMarqueeText
import me.eternal.purrfectsnap.ui.util.Motion
/**
* Styling configuration for the FloatingTopBar.
*/
@Immutable
data class FloatingTopBarColors(
val container: Color,
val borderStart: Color,
val borderEnd: Color
val border: Brush
)
@Composable
fun rememberDefaultFloatingTopBarColors(): FloatingTopBarColors {
return remember {
FloatingTopBarColors(
container = Color.White.copy(alpha = 0.12f),
borderStart = PurrfectPalette.glowPrimary.copy(alpha = 0.6f),
borderEnd = PurrfectPalette.glowSecondary.copy(alpha = 0.4f)
container = Color.White.copy(alpha = 0.07f),
border = Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
)
}
}
/**
* A premium, morphing top bar that transitions from a "Floating Island" pill
* to an "Infinity" sticky bar during scroll.
*
* DESIGN FEATURES:
* - Geometric Morphing: Pill shape to top-bleeding sticky bar.
* - Path-based Borders: Custom rounded path that surgically removes the top edge during stickiness.
* - Under-Glass Glow: A refractive dissolve layer that anchors the header to content.
* - Edge-Focus: Icons shift horizontally toward edges to maximize title space.
*/
@Composable
fun FloatingTopBar(
title: String,
subtitle: String? = null,
onBack: (() -> Unit)? = null,
modifier: Modifier = Modifier,
scrollOffset: Int = 0,
containerAlpha: Float = 1f,
actions: @Composable RowScope.() -> Unit = {},
colors: FloatingTopBarColors = rememberDefaultFloatingTopBarColors()
) {
val haptic = LocalHapticFeedback.current
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
// Calculate morphing factor based on scroll progress
val focusFactor by remember(scrollOffset) {
derivedStateOf { (scrollOffset.toFloat() / Motion.HEADER_MORPH_THRESHOLD).coerceIn(0f, 1f) }
}
val morphingParams by remember(focusFactor, statusBarHeight) {
derivedStateOf {
object {
val headerHeight = lerp(64.dp, 56.dp, focusFactor)
val sidePadding = lerp(14.dp, 0.dp, focusFactor)
val containerTopPadding = lerp(statusBarHeight + 4.dp, 0.dp, focusFactor)
val internalTopPadding = lerp(0.dp, statusBarHeight, focusFactor)
val internalVerticalPadding = lerp(8.dp, 0.dp, focusFactor)
val topCorners = lerp(26.dp, 0.dp, focusFactor)
val bottomCorners = lerp(26.dp, 28.dp, focusFactor)
val subtitleAlpha = (1f - (focusFactor * 2.5f)).coerceIn(0f, 1f)
val subtitleTranslationY = lerp(0.dp, (-10).dp, focusFactor)
val iconScale = 1f - (0.12f * focusFactor)
val horizontalShift = (6 * focusFactor).dp
}
}
}
// Trigger tactile feedback when header reaches full expansion
var hasSnapped by remember { mutableStateOf(false) }
LaunchedEffect(focusFactor) {
if (focusFactor >= 1f && !hasSnapped) {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
hasSnapped = true
} else if (focusFactor < 0.9f) {
hasSnapped = false
}
}
val shape = remember(morphingParams.topCorners, morphingParams.bottomCorners) {
RoundedCornerShape(
topStart = morphingParams.topCorners,
topEnd = morphingParams.topCorners,
bottomStart = morphingParams.bottomCorners,
bottomEnd = morphingParams.bottomCorners
)
}
val borderPath = remember { Path() }
val uPath = remember { Path() }
val refractiveColor = remember { Color(0xFF241F52) }
Box(modifier = modifier.fillMaxWidth().zIndex(10f)) {
// --- 1. Refractive background layer ---
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = morphingParams.sidePadding)
.padding(top = morphingParams.containerTopPadding)
.height(morphingParams.internalTopPadding + morphingParams.headerHeight + 32.dp)
.background(
Brush.verticalGradient(
0.0f to refractiveColor.copy(alpha = 0.95f * focusFactor),
0.6f to refractiveColor.copy(alpha = 0.85f * focusFactor),
1.0f to Color.Transparent
)
)
)
// --- 2. Primary header surface ---
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = morphingParams.sidePadding)
.padding(top = morphingParams.containerTopPadding)
.graphicsLayer {
alpha = containerAlpha
},
shape = shape,
color = Color.Transparent,
tonalElevation = 0.dp,
shadowElevation = (4 * focusFactor).dp
val shape = RoundedCornerShape(26.dp)
Surface(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp)
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()),
shape = shape,
color = colors.container,
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, colors.border)
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(
Brush.verticalGradient(
listOf(
Color(0xFF1B152E).copy(alpha = 0.85f + (0.1f * focusFactor)),
refractiveColor.copy(alpha = 0.85f + (0.1f * focusFactor))
)
)
if (onBack != null) {
IconButton(onClick = onBack, modifier = Modifier.size(42.dp)) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
tint = Color.White
)
.drawBehind {
val strokeWidth = 1.dp.toPx()
val brush = Brush.linearGradient(listOf(colors.borderStart, colors.borderEnd))
val tr = morphingParams.topCorners.toPx()
val br = morphingParams.bottomCorners.toPx()
if (focusFactor > 0.9f) {
uPath.reset()
uPath.apply {
moveTo(0f, 0f)
lineTo(0f, size.height - br)
arcTo(androidx.compose.ui.geometry.Rect(0f, size.height - 2*br, 2*br, size.height), 180f, -90f, false)
lineTo(size.width - br, size.height)
arcTo(androidx.compose.ui.geometry.Rect(size.width - 2*br, size.height - 2*br, size.width, size.height), 90f, -90f, false)
lineTo(size.width, 0f)
}
drawPath(uPath, brush, style = Stroke(strokeWidth))
} else {
borderPath.reset()
borderPath.apply {
moveTo(tr, 0f)
lineTo(size.width - tr, 0f)
arcTo(androidx.compose.ui.geometry.Rect(size.width - 2*tr, 0f, size.width, 2*tr), 270f, 90f, false)
lineTo(size.width, size.height - br)
arcTo(androidx.compose.ui.geometry.Rect(size.width - 2*br, size.height - 2*br, size.width, size.height), 0f, 90f, false)
lineTo(br, size.height)
arcTo(androidx.compose.ui.geometry.Rect(0f, size.height - 2*br, 2*br, size.height), 90f, 90f, false)
lineTo(0f, tr)
arcTo(androidx.compose.ui.geometry.Rect(0f, 0f, 2*tr, 2*tr), 180f, 90f, false)
}
drawPath(borderPath, brush, style = Stroke(strokeWidth))
}
}
) {
// --- 3. Header layout content ---
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = morphingParams.internalTopPadding)
.padding(horizontal = 16.dp, vertical = morphingParams.internalVerticalPadding)
.height(morphingParams.headerHeight),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
if (onBack != null) {
IconButton(
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onBack()
},
modifier = Modifier
.size(44.dp)
.graphicsLayer {
scaleX = morphingParams.iconScale
scaleY = morphingParams.iconScale
translationX = -morphingParams.horizontalShift.toPx()
}
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
tint = Color.White
)
}
}
Column(
modifier = Modifier
.weight(1f)
.padding(vertical = 2.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.Start
) {
Text(
text = title,
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 19.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
)
if (!subtitle.isNullOrBlank() && morphingParams.subtitleAlpha > 0.01f) {
PurrfectMarqueeText(
text = subtitle,
color = PurrfectPalette.textSecondary.copy(alpha = morphingParams.subtitleAlpha),
style = TextStyle(fontSize = 13.sp),
textAlign = TextAlign.Start,
contentAlignment = Alignment.CenterStart,
enabled = true,
modifier = Modifier
.fillMaxWidth()
.graphicsLayer {
translationY = morphingParams.subtitleTranslationY.toPx()
alpha = morphingParams.subtitleAlpha
}
)
}
}
Row(
modifier = Modifier
.wrapContentWidth()
.graphicsLayer {
scaleX = morphingParams.iconScale
scaleY = morphingParams.iconScale
translationX = morphingParams.horizontalShift.toPx()
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
actions()
}
}
} else {
Spacer(modifier = Modifier.height(0.dp))
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (!subtitle.isNullOrBlank()) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = subtitle,
color = PurrfectPalette.textSecondary,
fontSize = 12.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
Box(contentAlignment = Alignment.CenterEnd) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
content = actions
)
}
}
}

View File

@@ -14,7 +14,6 @@ object Updater {
val versionName: String,
val releaseUrl: String,
val workflowId: Long?,
val body: String? = null,
val assetDownloads: Map<String, String> = emptyMap(),
)
@@ -39,11 +38,10 @@ object Updater {
private fun fetchLatestRelease(channel: Channel) = runCatching {
val endpoint = Request.Builder().url("https://api.github.com/repos/particle-box/PurrfectSnap/releases").build()
val response = OkHttpClient().newCall(endpoint).execute()
val body = response.body?.string() ?: throw Throwable("Empty response body")
if (!response.isSuccessful) throw Throwable("Failed to fetch releases: ${response.code}")
val releases = JsonParser.parseString(body).asJsonArray.also {
val releases = JsonParser.parseString(response.body?.string()).asJsonArray.also {
if (it.size() == 0) throw Throwable("No releases found")
}
@@ -82,7 +80,6 @@ object Updater {
releaseUrl = latestRelease.getAsJsonPrimitive("html_url")?.asString
?: endpoint.url.toString().replace("api.", "").replace("repos/", ""),
workflowId = null,
body = latestRelease.get("body")?.asString,
assetDownloads = assetDownloads
)
}.onFailure {
@@ -90,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?branch=dev&status=success").build()).execute().use {
val actionRuns = OkHttpClient().newCall(Request.Builder().url("https://api.github.com/repos/particle-box/PurrfectSnap/actions/runs?event=workflow_dispatch&branch=dev").build()).execute().use {
if (!it.isSuccessful) throw Throwable("Failed to fetch CI runs: ${it.code}")
JsonParser.parseString(it.body?.string()).asJsonObject
}
val debugRuns = actionRuns.getAsJsonArray("workflow_runs")?.mapNotNull { it.asJsonObject }?.filter { run ->
run.getAsJsonPrimitive("name")?.asString == "PurrfectSnap Debug CI"
run.get("conclusion")?.takeIf { it.isJsonPrimitive }?.asString == "success" && run.getAsJsonPrimitive("path")?.asString == ".github/workflows/debug.yml"
} ?: throw Throwable("No debug CI runs found")
val latestRun = debugRuns.firstOrNull() ?: throw Throwable("No debug CI runs found")
@@ -107,36 +104,20 @@ object Updater {
versionName = headSha.substring(0, headSha.length.coerceAtMost(7)) + "-debug",
releaseUrl = latestRun.getAsJsonPrimitive("html_url")?.asString ?: return@runCatching null,
workflowId = latestRun.getAsJsonPrimitive("id")?.asLong,
body = latestRun.get("head_commit")?.asJsonObject?.get("message")?.asString
)
}.onFailure {
AbstractLogger.directError("Failed to fetch latest debug CI", it)
}.getOrNull()
private val cache = java.util.concurrent.ConcurrentHashMap<Channel, Pair<Long, Result<LatestRelease?>>>()
private val cache = mutableMapOf<Channel, LatestRelease?>()
fun getLatestRelease(channel: Channel): LatestRelease? {
val cached = cache[channel]
val now = System.currentTimeMillis()
if (cached != null) {
val (timestamp, result) = cached
// Define Cache TTL: 24 hours for any successful API response, 10 minutes for error
val ttl = if (result.isSuccess) 24 * 60 * 60 * 1000L else 10 * 60 * 1000L
if ((now - timestamp) < ttl) {
return result.getOrNull()
}
}
val result = runCatching {
if (channel == Channel.PRERELEASE) {
return cache.getOrPut(channel) {
if (BuildConfig.DEBUG) {
fetchLatestDebugCI() ?: fetchLatestRelease(channel)
} else {
fetchLatestRelease(channel)
}
}
cache[channel] = now to result
return result.getOrNull()
}
}

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.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.border
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 me.eternal.purrfectsnap.ui.util.headerHeightTracker
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -157,8 +157,8 @@ class FileImportsRoot: Routes.Route() {
.padding(horizontal = 10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = PaddingValues(
top = topBarHeight,
bottom = routes.bottomPadding
top = topBarHeight + 8.dp,
bottom = routes.bottomPadding + 16.dp
)
) {
item {
@@ -270,7 +270,9 @@ class FileImportsRoot: Routes.Route() {
title = titleText ?: translation["import_file_button"],
subtitle = null,
onBack = { routes.navController.popBackStack() },
modifier = Modifier.headerHeightTracker { topBarHeight = it }
modifier = Modifier.onGloballyPositioned {
topBarHeight = with(density) { it.size.height.toDp() }
}
)
}
}

View File

@@ -18,7 +18,6 @@ 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
@@ -39,8 +38,6 @@ 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
@@ -232,11 +229,9 @@ class LoggerHistoryRoot : Routes.Route() {
.padding(2.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
attachments.forEachIndexed { index, attachment ->
Button(
onClick = {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
context.coroutineScope.launch {
runCatching {
downloadAttachment(message.sendTimestamp, attachment)
@@ -288,148 +283,70 @@ class LoggerHistoryRoot : Routes.Route() {
}
val conversationInfoCache = remember { ConcurrentHashMap<String, String?>() }
val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
Box(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
val listState = rememberLazyListState()
val density = androidx.compose.ui.platform.LocalDensity.current
var controlsHeight by remember { mutableStateOf(100.dp) }
Column(modifier = Modifier.fillMaxSize()) {
FloatingTopBar(
title = context.translation["manager.routes.logger_history"] ?: "Logger History",
onBack = { routes.navController.popBackStack() }
)
LaunchedEffect(listState.firstVisibleItemScrollOffset, listState.firstVisibleItemIndex) {
val offset = if (listState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else listState.firstVisibleItemScrollOffset
routes.navigation?.globalScrollOffset = offset
}
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp)) {
var expanded by remember { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp)) {
Spacer(modifier = Modifier.height(controlsHeight))
var expanded by remember { mutableStateOf(false) }
Surface(
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(
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))
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(22.dp))
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Column(
modifier = Modifier
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(22.dp))
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
) {
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
) {
fun formatConversationInfo(conversationInfo: ConversationInfo?): String? {
if (conversationInfo == null) return null
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))
}
}
}
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 = {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
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
)
})
val selectedConversationInfo by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(selectedConversation)) {
selectedConversation?.let {
conversationInfoCache.getOrPut(it) {
formatConversationInfo(loggerWrapper.getConversationInfo(it))
}
}
}
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 = {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
stringFilter = ""
}) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = translation["close_button_description"],
tint = PurrfectPalette.textSecondary
)
}
}
} else null,
value = selectedConversationInfo ?: translation["select_conversation_placeholder"],
onValueChange = {},
readOnly = true,
modifier = Modifier
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
.fillMaxWidth(),
colors = TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
@@ -441,102 +358,160 @@ class LoggerHistoryRoot : Routes.Route() {
)
)
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 = {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
reverseOrder = it
},
colors = CheckboxDefaults.colors(
checkedColor = PurrfectPalette.glowPrimary,
checkmarkColor = Color.White,
uncheckedColor = Color.White.copy(alpha = 0.35f)
)
)
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
)
})
}
}
}
}
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
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
)
}
}
} 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 = if (listState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else listState.firstVisibleItemScrollOffset,
modifier = Modifier.headerHeightTracker { controlsHeight = it }
)
}
}
}
}
}

View File

@@ -1,29 +1,18 @@
@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
@@ -43,7 +32,6 @@ 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() {
@@ -177,74 +165,44 @@ 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) }
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)
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 8.dp, top = 8.dp, end = 8.dp, bottom = 8.dp + routes.bottomPadding),
) {
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)
}
item {
if (repositories.isEmpty()) {
Text(translation["no_repos_added"], modifier = Modifier
.padding(16.dp)
.fillMaxWidth(), fontSize = 15.sp, fontWeight = FontWeight.Light, textAlign = TextAlign.Center)
}
items(repositories) { url ->
Surface(
}
items(repositories) { url ->
ElevatedCard(onClick = {
context.androidContext.copyToClipboard(url)
}, modifier = Modifier.animateContentSize()) {
Row(
modifier = Modifier
.fillMaxWidth()
.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))
.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
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"])
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()
}
}
) {
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.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
@@ -41,8 +41,6 @@ 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
@@ -70,7 +68,6 @@ 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,12 +102,12 @@ class TasksRootSection : Routes.Route() {
it.deleteOnExit()
}
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 ->
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)
var read: Int
while (inputStream.read(buffer).also { read = it } != -1) {
@@ -278,7 +275,6 @@ class TasksRootSection : Routes.Route() {
}
if (showDeleteFiles) {
val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
Surface(
shape = RoundedCornerShape(18.dp),
color = Color.White.copy(alpha = 0.04f),
@@ -289,38 +285,33 @@ class TasksRootSection : Routes.Route() {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
onToggleDeleteFiles(!deleteFilesChecked)
}
.clickable { onToggleDeleteFiles(!deleteFilesChecked) }
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Checkbox(
checked = deleteFilesChecked,
onCheckedChange = {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
onToggleDeleteFiles(it)
},
onCheckedChange = { onToggleDeleteFiles(it) },
colors = CheckboxDefaults.colors(
checkedColor = PurrfectPalette.glowPrimary,
uncheckedColor = Color.White,
checkmarkColor = Color.Black
)
)
Column {
Text(
text = translation["delete_files_option"] ?: "Delete Files",
color = Color.White,
fontWeight = FontWeight.SemiBold
)
Text(
text = translation["delete_files_option_hint"] ?: "Also remove downloaded files",
color = PurrfectPalette.textSecondary,
style = MaterialTheme.typography.bodySmall
)
} }
Column {
Text(
text = context.translation["delete_files_option"],
color = Color.White,
fontWeight = FontWeight.SemiBold
)
Text(
text = context.translation["delete_files_option_hint"] ?: "Also remove downloaded files",
color = PurrfectPalette.textSecondary,
style = MaterialTheme.typography.bodySmall
)
}
}
}
}
@@ -328,12 +319,8 @@ class TasksRootSection : Routes.Route() {
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)
) {
val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
Button(
onClick = {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
onDismiss()
},
onClick = onDismiss,
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.08f),
contentColor = Color.White
@@ -342,10 +329,7 @@ class TasksRootSection : Routes.Route() {
Text(context.translation["button.negative"])
}
Button(
onClick = {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
onConfirm()
},
onClick = onConfirm,
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
contentColor = Color.White
@@ -405,7 +389,59 @@ class TasksRootSection : Routes.Route() {
}
}
override val topBarActions: @Composable (RowScope.() -> Unit) = {}
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 }
)
}
}
@Composable
private fun TaskCard(modifier: Modifier, task: Task, pendingTask: PendingTask? = null) {
@@ -466,12 +502,10 @@ class TasksRootSection : Routes.Route() {
}
val isActive = pendingTask != null && !taskStatus.isFinalStage()
val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
val cardModifier = modifier
.pointerInput(Unit) {
detectTapGestures(
onTap = {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
if (taskSelection.isNotEmpty()) {
toggleSelection()
return@detectTapGestures
@@ -479,7 +513,6 @@ class TasksRootSection : Routes.Route() {
openFile()
},
onLongPress = {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
if (taskSelection.isNotEmpty()) {
openFile()
return@detectTapGestures
@@ -580,7 +613,7 @@ class TasksRootSection : Routes.Route() {
} else {
when {
!isDocumentFileReadable -> Icon(Icons.Filled.DeleteOutline, contentDescription = "File not found")
documentFileMimeType.contains("image") -> Icon(Icons.Filled.Photo, contentDescription = "Image")
documentFileMimeType.contains("image") -> Icon(Icons.Filled.Image, 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")
@@ -811,14 +844,6 @@ 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?) }
@@ -858,101 +883,151 @@ class TasksRootSection : Routes.Route() {
.background(PurrfectPalette.backgroundGradient)
) {
Column(modifier = Modifier.fillMaxSize()) {
val subtitle = if (activeTasks.isNotEmpty()) {
translation.format(
"summary_active",
"active" to activeTasks.size.toString(),
"recent" to recentTasks.size.toString()
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)
)
)
)
} 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 = {
val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
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 {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
) {
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 = {
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 = 6.dp),
verticalAlignment = Alignment.CenterVertically
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Filled.Merge,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(6.dp))
Icon(Icons.Filled.PlaylistAddCheckCircle, contentDescription = null, tint = Color.White)
Text(
translation["merge_button"],
text = translation.format("running_count", "count" to activeTasks.size.toString()),
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.Bold
fontWeight = FontWeight.SemiBold,
fontSize = 12.sp
)
}
}
}
IconButton(onClick = {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
if (taskSelection.isEmpty()) {
showConfirmDialog = true
} else {
showConfirmDialog = true
IconButton(onClick = { showConfirmDialog = true }) {
Icon(Icons.Filled.Delete, contentDescription = translation["clear_button_description"], tint = Color.White)
}
}) {
Icon(Icons.Filled.DeleteSweep, contentDescription = translation["clear_button_description"], tint = Color.White)
}
}
)
}
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)
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))
) {
item {
if (activeTasks.isEmpty() && recentTasks.isEmpty()) {
TasksEmptyState(text = translation["no_tasks"])
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"])
}
}
}
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()
}
}
}
}
@@ -970,8 +1045,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,24 +1,45 @@
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.*
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.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.*
import androidx.compose.runtime.*
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
@@ -29,10 +50,7 @@ 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.purrfectSwitchColors
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.util.saveFile
import me.eternal.purrfectsnap.storage.getLocationCoordinates
import org.json.JSONArray
@@ -57,17 +75,14 @@ class ConfigExportSummaryScreen : Routes.Route() {
for (key in properties.keys()) {
val value = properties.get(key)
val currentPrefix = if (prefix.isEmpty()) key else "$prefix.$key"
// Handle nested features with their own state and sub-properties
if (value is JSONObject && value.has("state") && value.has("properties")) {
val featureNameKey = "features.properties.$categoryKey.properties.${currentPrefix.split('.').joinToString(".properties.")}.name"
val featureName = context.translation[featureNameKey] ?: key
featureList.add(ImportedFeature(niceCategoryName, featureName, key, value.getBoolean("state"), indent))
parseProperties(categoryKey, niceCategoryName, value.getJSONObject("properties"), currentPrefix, indent + 1)
} else if (value is JSONObject && value.has("properties")) {
// Handle purely structural containers
parseProperties(categoryKey, niceCategoryName, value.getJSONObject("properties"), currentPrefix, indent)
} else {
// Handle terminal leaf properties (strings, ints, etc.)
val featureNameKey = "features.properties.$categoryKey.properties.${currentPrefix.split('.').joinToString(".properties.")}.name"
var featureName = context.translation[featureNameKey] ?: key
if (key == "save_folder") {
@@ -81,7 +96,6 @@ class ConfigExportSummaryScreen : Routes.Route() {
val value = json.get(categoryKey)
if (value is JSONObject) {
val niceCategoryName = context.translation["features.properties.$categoryKey.name"] ?: categoryKey.replaceFirstChar { it.uppercase() }
// Process top-level features or recursively descend into property containers
if (value.has("state") && !value.has("properties")) {
featureList.add(ImportedFeature(niceCategoryName, translation["enable_feature"], categoryKey, value.getBoolean("state"), 0))
} else if (value.has("properties")) {
@@ -122,177 +136,200 @@ class ConfigExportSummaryScreen : Routes.Route() {
}
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
var exportSensitiveData by remember { mutableStateOf(it.arguments?.getString("exportSensitiveData")?.toBoolean() ?: false) }
var includeSavedLocations by remember { mutableStateOf(it.arguments?.getString("includeSavedLocations")?.toBoolean() ?: false) }
val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
val exportSensitiveData = it.arguments?.getString("exportSensitiveData")?.toBoolean() ?: false
val includeSavedLocations = it.arguments?.getString("includeSavedLocations")?.toBoolean() ?: false
val exportLabel = context.translation["manager.sections.features.export_option"]
val parser = remember { ConfigParser() }
val featuresByCategory by remember(exportSensitiveData, includeSavedLocations) {
derivedStateOf {
val savedLocations = if (includeSavedLocations) context.database.getLocationCoordinates() else null
parser.parse(context.config.exportToString(exportSensitiveData, includeSavedLocations, savedLocations))
}
val savedLocations = remember {
if (includeSavedLocations) context.database.getLocationCoordinates() else null
}
val featuresByCategory = remember {
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)
) {
LazyColumn(
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 12.dp),
state = listState,
contentPadding = PaddingValues(
top = controlsHeight,
bottom = routes.bottomPadding
),
verticalArrangement = Arrangement.spacedBy(10.dp)
.statusBarsPadding()
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Bottom))
) {
item {
Surface(
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
.fillMaxWidth()
.padding(vertical = 4.dp),
shape = RoundedCornerShape(18.dp),
color = PurrfectPalette.cardOverlayColor,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f))
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(24.dp))
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = context.translation["manager.dialogs.export_config.content"] ?: "Export Sensitive Data",
color = Color.White,
fontSize = 15.sp,
fontWeight = FontWeight.Medium
)
Switch(
checked = exportSensitiveData,
onCheckedChange = {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
exportSensitiveData = it
},
colors = purrfectSwitchColors()
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = context.translation["manager.sections.features.include_saved_locations"] ?: "Include Saved Locations",
color = Color.White,
fontSize = 15.sp,
fontWeight = FontWeight.Medium
)
Switch(
checked = includeSavedLocations,
onCheckedChange = {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
includeSavedLocations = it
},
colors = purrfectSwitchColors()
)
}
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)
}
}
}
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 {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
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)
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)
)
)
)
)
) {
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
)
) {
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
)
}
}
IconButton(onClick = {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
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
)
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 = 6.dp),
modifier = Modifier.padding(start = (feature.indentation * 16).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
)
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
)
}
}
}
}
}
}
is String -> {
Column(
@@ -317,6 +354,7 @@ class ConfigExportSummaryScreen : Routes.Route() {
if (index < features.size - 1) {
Spacer(modifier = Modifier.height(6.dp))
}
}
}
}
}
@@ -324,37 +362,6 @@ class ConfigExportSummaryScreen : Routes.Route() {
}
}
}
FloatingTopBar(
title = translation["title"],
onBack = { routes.navController.popBackStack() },
scrollOffset = if (listState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else listState.firstVisibleItemScrollOffset,
modifier = Modifier.headerHeightTracker { controlsHeight = it },
actions = {
IconButton(onClick = {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
routes.activityLauncher.saveFile("config.json", "application/json") { uri ->
runCatching {
context.androidContext.contentResolver.openOutputStream(android.net.Uri.parse(uri))?.use {
context.config.writeConfig()
val savedLocations = if (includeSavedLocations) context.database.getLocationCoordinates() else null
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,24 +1,43 @@
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.*
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.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.*
import androidx.compose.runtime.*
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
@@ -34,9 +53,7 @@ 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
@@ -76,8 +93,8 @@ class ConfigImportConfirmationScreen : Routes.Route() {
abs(existing.longitude - longitude) < COORDINATE_TOLERANCE
}
// No duplicate found, add as new location
if (existingMatch == null) {
// No duplicate found, add as new location
val newLocation = LocationCoordinates().apply {
this.name = name
this.latitude = latitude
@@ -104,7 +121,6 @@ class ConfigImportConfirmationScreen : Routes.Route() {
for (key in properties.keys()) {
val value = properties.get(key)
val currentPrefix = if (prefix.isEmpty()) key else "$prefix.$key"
// Handle nested features with their own state and sub-properties
if (value is JSONObject && value.has("state") && value.has("properties")) {
val featureNameKey =
"features.properties.$categoryKey.properties.${currentPrefix.split('.')
@@ -127,7 +143,6 @@ class ConfigImportConfirmationScreen : Routes.Route() {
indent + 1
)
} else if (value is JSONObject && value.has("properties")) {
// Handle purely structural containers
parseProperties(
categoryKey,
niceCategoryName,
@@ -136,7 +151,6 @@ class ConfigImportConfirmationScreen : Routes.Route() {
indent
)
} else {
// Handle terminal leaf properties (strings, ints, etc.)
val featureNameKey =
"features.properties.$categoryKey.properties.${currentPrefix.split('.')
.joinToString(".properties.")}.name"
@@ -159,7 +173,6 @@ class ConfigImportConfirmationScreen : Routes.Route() {
val niceCategoryName =
context.translation["features.properties.$categoryKey.name"]
?: categoryKey.replaceFirstChar { it.uppercase() }
// Process top-level features or recursively descend into property containers
if (value.has("state") && !value.has("properties")) {
featureList.add(
ImportedFeature(
@@ -220,26 +233,111 @@ 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)
) {
LazyColumn(
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 12.dp),
state = listState,
contentPadding = PaddingValues(
top = controlsHeight,
bottom = routes.bottomPadding
),
verticalArrangement = Arrangement.spacedBy(10.dp)
.statusBarsPadding()
) {
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)
@@ -341,46 +439,15 @@ 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,6 +26,7 @@ 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
@@ -47,7 +48,6 @@ 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,17 +223,25 @@ class ManageRuleFeature : Routes.Route() {
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
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),
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()),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
val headerShape = RoundedCornerShape(22.dp)
@@ -389,13 +397,6 @@ 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,19 +1,40 @@
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.*
import androidx.compose.runtime.*
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -23,7 +44,6 @@ 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
@@ -37,12 +57,9 @@ 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 me.eternal.purrfectsnap.ui.util.PurrfectMarqueeText
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import android.os.SystemClock
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))
@@ -50,34 +67,36 @@ class HomeAbout : Routes.Route() {
val scrollState = rememberScrollState()
val aboutStory = remember { translation["about_story"] }
val pagePadding = 16.dp
val bottomPadding = routes.bottomPadding
val bottomPadding = routes.bottomPadding +
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
24.dp
val tapSource = remember { MutableInteractionSource() }
val tapTimeoutMs = 1500L
val tapCount = remember { mutableIntStateOf(0) }
val lastTapTime = remember { mutableLongStateOf(0L) }
val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
LaunchedEffect(Unit) {
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(top = controlsHeight, bottom = bottomPadding)
.padding(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)
@@ -105,7 +124,6 @@ class HomeAbout : Routes.Route() {
interactionSource = tapSource,
indication = null
) {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
val now = SystemClock.elapsedRealtime()
if (now - lastTapTime.longValue > tapTimeoutMs) {
tapCount.intValue = 0
@@ -138,17 +156,15 @@ class HomeAbout : Routes.Route() {
verticalAlignment = Alignment.CenterVertically
) {
DeveloperCard(
name = translation["about_dev_external"],
name = "ΞTΞRNAL",
imageRes = R.drawable.pfp_external,
avenirNext = avenirNext,
haptic = haptic,
modifier = Modifier.weight(1f)
)
DeveloperCard(
name = translation["about_dev_rsr"],
name = "<RSR/>",
imageRes = R.drawable.pfp_rsr,
avenirNext = avenirNext,
haptic = haptic,
modifier = Modifier.weight(1f)
)
}
@@ -160,8 +176,7 @@ class HomeAbout : Routes.Route() {
Surface(
modifier = Modifier
.padding(horizontal = pagePadding)
.fillMaxWidth()
.widthIn(max = 500.dp),
.fillMaxWidth(),
shape = RoundedCornerShape(26.dp),
color = Color.Transparent,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)),
@@ -171,24 +186,20 @@ class HomeAbout : Routes.Route() {
Column(
modifier = Modifier
.background(PurrfectPalette.cardOverlay)
.padding(horizontal = 24.dp, vertical = 20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
.padding(horizontal = 20.dp, vertical = 18.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = translation["about_story_title"],
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
textAlign = TextAlign.Center
color = Color.White
)
Text(
text = aboutStory,
fontSize = 14.sp,
color = PurrfectPalette.textSecondary,
textAlign = TextAlign.Justify,
lineHeight = 22.sp,
modifier = Modifier.fillMaxWidth()
lineHeight = 20.sp
)
}
}
@@ -226,7 +237,6 @@ class HomeAbout : Routes.Route() {
Button(
modifier = Modifier.weight(1f),
onClick = {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
context.androidContext.openLink(
"https://github.com/particle-box/PurrfectSnap",
context.translation["toast_open_link_failed"]
@@ -248,7 +258,6 @@ class HomeAbout : Routes.Route() {
OutlinedButton(
modifier = Modifier.weight(1f),
onClick = {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
context.androidContext.openLink(
"https://t.me/purrfectsnap_official",
context.translation["toast_open_link_failed"]
@@ -269,14 +278,9 @@ class HomeAbout : Routes.Route() {
}
}
}
}
FloatingTopBar(
title = context.translation["manager.routes.home_about"] ?: "About Us",
onBack = { routes.navController.popBackStack() },
scrollOffset = scrollState.value,
modifier = Modifier.headerHeightTracker { controlsHeight = it }
)
Spacer(modifier = Modifier.height(32.dp))
}
}
}
@@ -285,7 +289,6 @@ class HomeAbout : Routes.Route() {
name: String,
imageRes: Int,
avenirNext: FontFamily,
haptic: androidx.compose.ui.hapticfeedback.HapticFeedback,
modifier: Modifier = Modifier
) {
val cardShape = RoundedCornerShape(20.dp)
@@ -297,9 +300,7 @@ class HomeAbout : Routes.Route() {
)
Surface(
modifier = modifier.clickable {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
},
modifier = modifier,
shape = cardShape,
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
@@ -315,10 +316,10 @@ class HomeAbout : Routes.Route() {
) {
Box(
modifier = Modifier
.size(76.dp)
.size(82.dp)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.1f))
.border(1.5.dp, imageRing, CircleShape)
.border(2.dp, imageRing, CircleShape)
) {
Image(
painter = painterResource(id = imageRes),
@@ -327,17 +328,16 @@ class HomeAbout : Routes.Route() {
modifier = Modifier.fillMaxSize()
)
}
PurrfectMarqueeText(
Text(
text = name,
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
fontFamily = avenirNext
),
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
modifier = Modifier.fillMaxWidth()
fontFamily = avenirNext,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}

View File

@@ -64,11 +64,8 @@ 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
@@ -76,9 +73,9 @@ import me.eternal.purrfectsnap.ui.util.saveFile
import me.eternal.purrfectsnap.common.util.ktx.copyToClipboard
class HomeLogs : Routes.Route() {
private val logListState = LazyListState()
private val logListState by lazy { LazyListState(0) }
private lateinit var activityLauncherHelper: ActivityLauncherHelper
private val externalRefreshTick = mutableIntStateOf(0)
private val externalRefreshTick = mutableStateOf(0)
override val init: () -> Unit = {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
}
@@ -112,19 +109,18 @@ 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
coroutineScope.launch(Dispatchers.Main) {
mainExecutor.execute {
visibleLogs.add(line)
}
}
@@ -145,7 +141,8 @@ class HomeLogs : Routes.Route() {
}
delay(220)
if (visibleLogs.isNotEmpty()) {
logListState.scrollToItem((visibleLogs.size - 1).coerceAtLeast(0))
val targetIndex = (visibleLogs.size - 1).coerceAtLeast(0)
logListState.scrollToItem(targetIndex)
}
isRefreshing = false
}
@@ -170,98 +167,49 @@ class HomeLogs : Routes.Route() {
.background(PurrfectPalette.backgroundGradient)
.pullRefresh(pullRefreshState)
) {
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)
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)
}
}
}
}
}
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,
@@ -322,6 +270,116 @@ 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,7 +55,6 @@ 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
@@ -174,7 +173,6 @@ class HomeSettings : Routes.Route() {
confirmButtonText = positiveLabel,
dismissButtonText = negativeLabel,
onConfirm = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
value = false
sharedPreferences.edit().putBoolean(realKey, false).apply()
showDisableDialog = false
@@ -189,7 +187,9 @@ class HomeSettings : Routes.Route() {
.fillMaxWidth()
.heightIn(min = 55.dp)
.clickable {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
val nextValue = !value
if (!nextValue && confirmDisableTitle != null) {
showDisableDialog = true
@@ -203,7 +203,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, color = Color.White)
Text(text = text, modifier = Modifier.padding(start = 26.dp, end = 16.dp), fontSize = 14.sp)
Switch(
checked = value,
onCheckedChange = null,
@@ -223,7 +223,9 @@ class HomeSettings : Routes.Route() {
.fillMaxWidth()
.heightIn(min = 55.dp)
.clickable {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
value = !value
sharedPreferences
.edit() {
@@ -233,7 +235,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, color = Color.White)
Text(text = text, modifier = Modifier.padding(start = 26.dp, end = 16.dp), fontSize = 14.sp)
Switch(
checked = value,
onCheckedChange = null,
@@ -245,12 +247,10 @@ class HomeSettings : Routes.Route() {
@Composable
private fun RowAction(key: String, requireConfirmation: Boolean = false, action: () -> Unit) {
val hapticFeedback = LocalHapticFeedback.current
var confirmationDialog by remember {
mutableStateOf(false)
}
fun takeAction() {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
if (requireConfirmation) {
confirmationDialog = true
} else {
@@ -260,7 +260,6 @@ class HomeSettings : Routes.Route() {
if (requireConfirmation && confirmationDialog) {
Dialog(onDismissRequest = { confirmationDialog = false }) {
dialogs.ConfirmDialog(title = context.translation["manager.dialogs.action_confirm.title"], onConfirm = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
action()
confirmationDialog = false
}, onDismiss = {
@@ -281,8 +280,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, 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) }
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) }
}
IconButton(onClick = { takeAction() },
modifier = Modifier.padding(end = 2.dp)
@@ -290,8 +289,7 @@ class HomeSettings : Routes.Route() {
Icon(
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
contentDescription = context.translation.getOrNull("actions.$key.name"),
modifier = Modifier.size(24.dp),
tint = Color.White
modifier = Modifier.size(24.dp)
)
}
}
@@ -315,12 +313,6 @@ class HomeSettings : Routes.Route() {
val contextC = LocalContext.current
val scope = rememberCoroutineScope()
val scrollState = rememberScrollState()
val hapticFeedback = LocalHapticFeedback.current
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"]
@@ -380,27 +372,109 @@ 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)
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(top = controlsHeight, bottom = routes.bottomPadding)
) {
Column(
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 = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
.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)
) {
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 ->
@@ -412,52 +486,56 @@ class HomeSettings : Routes.Route() {
GlassCard {
RowTitle(title = translation["ui_settings_title"])
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) }
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) }
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()
)
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()
)
}
}
}
}
@@ -468,28 +546,31 @@ 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) }
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))
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()
)
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()
)
}
}
AnimatedVisibility(visible = autoUpdateCheck) {
val spacingModifier = Modifier
@@ -547,14 +628,12 @@ class HomeSettings : Routes.Route() {
text = translation["reset_setup_action"],
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
lineHeight = 20.sp,
color = Color.White
lineHeight = 20.sp
)
Icon(
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
contentDescription = translation["reset_setup_action"],
modifier = Modifier.padding(end = 14.dp),
tint = Color.White
modifier = Modifier.padding(end = 14.dp)
)
}
}
@@ -854,83 +933,35 @@ 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), tint = Color.White)
Icon(Icons.Default.DeleteSweep, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(translation["clear_button"])
}
}
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"])
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"])
}
}
}
}
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 = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
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

@@ -53,7 +53,6 @@ fun QuickActionsDialog(
translation: LocaleWrapper
) {
val selected = remember { mutableStateListOf(*selectedQuickActions.toTypedArray()) }
val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
Dialog(onDismissRequest = onDismiss) {
val dialogShape = RoundedCornerShape(24.dp)
@@ -132,7 +131,6 @@ fun QuickActionsDialog(
modifier = Modifier
.fillMaxWidth()
.clickable {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
if (isSelected) selected.remove(name) else selected.add(name)
},
shape = RoundedCornerShape(16.dp),
@@ -176,7 +174,6 @@ fun QuickActionsDialog(
Switch(
checked = isSelected,
onCheckedChange = { toggled ->
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
if (toggled) selected.add(name) else selected.remove(name)
},
colors = purrfectSwitchColors()
@@ -190,17 +187,11 @@ fun QuickActionsDialog(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)
) {
TextButton(onClick = {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
onDismiss()
}) {
TextButton(onClick = onDismiss) {
Text(translation["button.cancel"], color = PurrfectPalette.textSecondary)
}
Button(
onClick = {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
onSave(selected.toList())
},
onClick = { onSave(selected.toList()) },
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.35f),
contentColor = Color.White

View File

@@ -73,7 +73,6 @@ 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() {
@@ -297,7 +296,10 @@ class ManageScriptReposSection : Routes.Route() {
onBack = { routes.navController.popBackStack() },
modifier = Modifier
.zIndex(2f)
.headerHeightTracker { topBarHeight = it }
.onGloballyPositioned {
val newHeight = with(density) { it.size.height.toDp() }
if (newHeight != topBarHeight) topBarHeight = newHeight
}
)
if (repositories.isEmpty()) {
Box(
@@ -317,9 +319,9 @@ class ManageScriptReposSection : Routes.Route() {
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 12.dp,
top = topBarHeight,
top = topBarHeight + 12.dp,
end = 12.dp,
bottom = routes.bottomPadding
bottom = 18.dp + routes.bottomPadding
),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {

View File

@@ -7,7 +7,6 @@ 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
@@ -23,15 +22,12 @@ 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
@@ -42,7 +38,6 @@ 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
@@ -52,8 +47,6 @@ 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
@@ -66,6 +59,7 @@ 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!!)
@@ -146,7 +140,7 @@ class ScriptingRootSection : Routes.Route() {
return@launch
}.onFailure {
context.log.error("Failed to import script", it)
context.shortToast(translation.format("import_failed", "message" to (it.message ?: "Unknown")))
context.shortToast(translation.format("import_failed", "message" to (it.message ?: context.translation["common.unknown"])))
}
isLoading = false
}
@@ -282,7 +276,6 @@ class ScriptingRootSection : Routes.Route() {
}
var openSettings by remember(script) { mutableStateOf(false) }
var openActions by remember { mutableStateOf(false) }
val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
val dispatcher = rememberAsyncUpdateDispatcher()
val reloadCallback = remember { suspend { dispatcher.dispatch() } }
@@ -315,12 +308,7 @@ class ScriptingRootSection : Routes.Route() {
Column(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = enabled) {
if (enabled) {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
openSettings = !openSettings
}
}
.clickable(enabled = enabled) { if (enabled) openSettings = !openSettings }
.background(PurrfectPalette.cardOverlay, cardShape)
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
@@ -397,16 +385,12 @@ class ScriptingRootSection : Routes.Route() {
}
}
}
IconButton(onClick = {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
openActions = !openActions
}) {
IconButton(onClick = { openActions = !openActions }) {
Icon(Icons.Default.Build, translation["actions_button"], tint = Color.White)
}
Switch(
checked = enabled,
onCheckedChange = { isChecked ->
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
openSettings = false
context.coroutineScope.launch(Dispatchers.IO) {
runCatching {
@@ -447,8 +431,7 @@ class ScriptingRootSection : Routes.Route() {
@Composable
private fun SelectFolderButton(onClick: () -> Unit) {
val label = translation.getOrNull("select_folder_button") ?: "Select folder"
val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
val label = translation["select_folder_button"]
Box(
modifier = Modifier
.fillMaxWidth()
@@ -456,13 +439,13 @@ class ScriptingRootSection : Routes.Route() {
contentAlignment = Alignment.Center
) {
Surface(
modifier = Modifier.size(68.dp),
modifier = Modifier.size(78.dp),
shape = CircleShape,
color = Color.White.copy(alpha = 0.1f),
color = PurrfectPalette.cardOverlayColor.copy(alpha = 0.9f),
tonalElevation = 0.dp,
shadowElevation = 10.dp,
shadowElevation = 14.dp,
border = BorderStroke(
1.dp,
1.5.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.7f),
@@ -473,28 +456,26 @@ class ScriptingRootSection : Routes.Route() {
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(6.dp)
.size(66.dp)
.clip(CircleShape)
.background(
Brush.radialGradient(
colors = listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.335f),
PurrfectPalette.glowSecondary.copy(alpha = 0.25f),
Color.Transparent
PurrfectPalette.glowPrimary.copy(alpha = 0.42f),
PurrfectPalette.glowSecondary.copy(alpha = 0.34f)
)
)
)
.clickable(onClick = {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
onClick()
}),
.border(1.dp, Color.White.copy(alpha = 0.14f), CircleShape)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = label,
tint = Color.White,
modifier = Modifier.size(32.dp)
modifier = Modifier.size(34.dp)
)
}
}
@@ -519,7 +500,6 @@ class ScriptingRootSection : Routes.Route() {
}
}
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
val scriptingFolder by rememberAsyncMutableState(
defaultValue = null,
@@ -528,14 +508,10 @@ 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 && pagerState.currentPage != 0) {
pagerState.scrollToPage(0)
if (scriptingFolder == null && selectedTab != 0) {
selectedTab = 0
}
}
@@ -556,12 +532,12 @@ class ScriptingRootSection : Routes.Route() {
) {
ScriptingHeader(
titles = tabTitles,
selectedTab = pagerState.currentPage,
selectedTab = selectedTab,
onTabSelected = { index ->
if (index == 1 && scriptingFolder == null) {
showToast = true
} else {
coroutineScope.launch { pagerState.animateScrollToPage(index) }
selectedTab = index
}
},
onImport = {
@@ -586,99 +562,15 @@ class ScriptingRootSection : Routes.Route() {
context.translation["toast_open_link_failed"]
)
},
folderSelected = scriptingFolder != null,
onPositioned = { controlsHeight = it }
folderSelected = scriptingFolder != null
)
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
)
}
}
}
}
Spacer(Modifier.height(12.dp))
when (selectedTab) {
0 -> InstalledTabContent(
scriptingFolder = scriptingFolder
)
1 -> CatalogTabContent(
scriptingFolder = scriptingFolder
)
}
}
@@ -686,8 +578,7 @@ class ScriptingRootSection : Routes.Route() {
@Composable
private fun InstalledTabContent(
scriptingFolder: DocumentFile?,
controlsHeight: androidx.compose.ui.unit.Dp
scriptingFolder: DocumentFile?
) {
val scriptModules by rememberAsyncMutableState(
defaultValue = emptyList(),
@@ -714,144 +605,229 @@ class ScriptingRootSection : Routes.Route() {
Box(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 12.dp)
) {
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)
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))
) {
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(
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()
.padding(horizontal = 20.dp, vertical = 22.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp)
.heightIn(min = 260.dp),
contentAlignment = Alignment.Center
) {
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)
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)
)
)
)
}
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() }
) {
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(
modifier = Modifier
.fillMaxWidth()
.height(220.dp),
contentAlignment = Alignment.Center
) {
AestheticEmptyState(
icon = Icons.Default.DataObject,
title = translation["no_scripts_found_title"],
subtitle = translation["use_catalog_to_add_scripts"],
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 22.dp)
)
}
}
}
} else if (scriptModules.isEmpty()) {
Box(
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(
modifier = Modifier
.fillMaxWidth()
.height(220.dp),
contentAlignment = Alignment.Center
.padding(horizontal = 14.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
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.",
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 22.dp)
)
.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
)
}
}
}
}
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?,
controlsHeight: androidx.compose.ui.unit.Dp
scriptingFolder: DocumentFile?
) {
val coroutineScope = rememberCoroutineScope()
Box(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 12.dp)
) {
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)
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)
}
}
}
}
@@ -865,67 +841,71 @@ class ScriptingRootSection : Routes.Route() {
onOpenFolder: () -> Unit,
onManageRepos: () -> Unit,
onDocs: () -> Unit,
folderSelected: Boolean,
onPositioned: (androidx.compose.ui.unit.Dp) -> Unit = {}
folderSelected: Boolean
) {
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)
val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
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 = {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
onDocs()
}) {
Icon(Icons.Default.CollectionsBookmark, contentDescription = translation["documentation_button"], tint = Color.White)
}
IconButton(onClick = {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
onManageRepos()
}) {
Icon(Icons.Default.Public, contentDescription = translation["manage_repos_button"], tint = Color.White)
}
IconButton(onClick = {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
onOpenFolder()
}) {
Icon(Icons.Default.FolderOpen, contentDescription = translation["open_scripts_folder_button"], tint = Color.White)
}
IconButton(onClick = {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
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))
}
}
)
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 = { index ->
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
onTabSelected(index)
}
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
)
}
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))
}
}
}
ScriptingTabSwitcher(
titles = titles,
selectedTab = selectedTab,
onTabSelected = onTabSelected
)
}
}
}
@@ -976,5 +956,5 @@ class ScriptingRootSection : Routes.Route() {
}
}
override val topBarActions: @Composable RowScope.() -> Unit = {}
override val topBarActions: @Composable() (RowScope.() -> Unit) = {}
}

View File

@@ -21,6 +21,7 @@ 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
@@ -46,7 +47,6 @@ 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,13 +118,16 @@ class ManageScope: Routes.Route() {
},
modifier = Modifier
.zIndex(2f)
.headerHeightTracker { topBarHeight = it }
.onGloballyPositioned {
val newHeight = with(density) { it.size.height.toDp() }
if (newHeight != topBarHeight) topBarHeight = newHeight
}
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(top = topBarHeight)
.padding(top = topBarHeight + 8.dp)
.verticalScroll(rememberScrollState())
) {
var bottomComposable by remember {
@@ -364,19 +367,29 @@ class ManageScope: Routes.Route() {
private fun computeStreakETA(timestamp: Long): String? {
val now = System.currentTimeMillis()
val stringBuilder = StringBuilder()
val diff = timestamp - now
val seconds = diff / 1000
val minutes = seconds / 60
val hours = minutes / 60
val days = hours / 24
return when {
days > 0 -> translation.format(if (days == 1L) "eta_day" else "eta_days", "count" to days.toString())
hours > 0 -> translation.format(if (hours == 1L) "eta_hour" else "eta_hours", "count" to hours.toString())
minutes > 0 -> translation.format(if (minutes == 1L) "eta_minute" else "eta_minutes", "count" to minutes.toString())
seconds > 0 -> translation.format(if (seconds == 1L) "eta_second" else "eta_seconds", "count" to seconds.toString())
else -> null
if (days > 0) {
stringBuilder.append("$days day ")
return stringBuilder.toString()
}
if (hours > 0) {
stringBuilder.append("$hours hours ")
return stringBuilder.toString()
}
if (minutes > 0) {
stringBuilder.append("$minutes minutes ")
return stringBuilder.toString()
}
if (seconds > 0) {
stringBuilder.append("$seconds seconds ")
return stringBuilder.toString()
}
return null
}
@OptIn(ExperimentalEncodingApi::class)

View File

@@ -31,6 +31,7 @@ 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
@@ -58,7 +59,6 @@ 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,13 +767,16 @@ class MessagingPreview: Routes.Route() {
},
modifier = Modifier
.zIndex(2f)
.headerHeightTracker { topBarHeight = it }
.onGloballyPositioned {
val newHeight = with(density) { it.size.height.toDp() }
if (newHeight != topBarHeight) topBarHeight = newHeight
}
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(top = topBarHeight)
.padding(top = topBarHeight + 6.dp)
.padding(horizontal = 14.dp)
) {
if (hasBridgeError) {

View File

@@ -7,7 +7,6 @@ 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
@@ -28,15 +27,11 @@ 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
@@ -67,8 +62,7 @@ class SocialRootSection : Routes.Route() {
private fun ScopeList(
scope: SocialScope,
friends: List<MessagingFriendInfo>,
groups: List<MessagingGroupInfo>,
controlsHeight: androidx.compose.ui.unit.Dp
groups: List<MessagingGroupInfo>
) {
val remainingHours = remember { context.config.root.streaksReminder.remainingHours.get() }
val list = when (scope) {
@@ -76,17 +70,10 @@ 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(),
state = listState,
contentPadding = PaddingValues(start = 10.dp, end = 10.dp, top = controlsHeight, bottom = routes.bottomPadding),
contentPadding = PaddingValues(start = 10.dp, end = 10.dp, bottom = routes.bottomPadding + 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
//check if scope list is empty
@@ -127,7 +114,6 @@ class SocialRootSection : Routes.Route() {
override val floatingActionButton: @Composable () -> Unit = {
var addFriendDialog by remember { mutableStateOf(null as AddFriendDialog?) }
val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
if (addFriendDialog != null) {
addFriendDialog?.Content {
@@ -142,7 +128,6 @@ class SocialRootSection : Routes.Route() {
FloatingActionButton(
onClick = {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
addFriendDialog = AddFriendDialog(
context,
AddFriendDialog.Actions(
@@ -209,8 +194,6 @@ 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()
@@ -251,11 +234,10 @@ 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"] ?: "Search"
val searchHint = context.translation["manager.dialogs.add_friend.search_hint"]
val searchShape = RoundedCornerShape(18.dp)
val searchBorder = Brush.linearGradient(
listOf(
@@ -325,8 +307,8 @@ class SocialRootSection : Routes.Route() {
state = pagerState
) { page ->
when (page) {
0 -> ScopeList(SocialScope.FRIEND, filteredFriends, filteredGroups, controlsHeight = controlsHeight)
1 -> ScopeList(SocialScope.GROUP, filteredFriends, filteredGroups, controlsHeight = controlsHeight)
0 -> ScopeList(SocialScope.FRIEND, filteredFriends, filteredGroups)
1 -> ScopeList(SocialScope.GROUP, filteredFriends, filteredGroups)
}
}
}
@@ -341,7 +323,6 @@ class SocialRootSection : Routes.Route() {
onPreview: () -> Unit,
remainingHours: Int
) {
val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
val cardGradient = Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.22f),
@@ -354,10 +335,7 @@ class SocialRootSection : Routes.Route() {
.heightIn(min = 88.dp)
.border(1.dp, cardGradient, RoundedCornerShape(20.dp)),
shape = RoundedCornerShape(20.dp),
onClick = {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
onManage()
},
onClick = onManage,
colors = CardDefaults.elevatedCardColors(
containerColor = Color.Transparent
)
@@ -397,7 +375,7 @@ class SocialRootSection : Routes.Route() {
fontSize = 15.sp
)
Text(
text = translation["groups_tab"] ?: "Groups",
text = translation["groups_tab"],
color = PurrfectPalette.textSecondary,
fontSize = 12.sp
)
@@ -467,10 +445,7 @@ class SocialRootSection : Routes.Route() {
}
Surface(
onClick = {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
onPreview()
},
onClick = onPreview,
shape = RoundedCornerShape(16.dp),
color = Color.White.copy(alpha = 0.08f),
tonalElevation = 0.dp,
@@ -510,57 +485,68 @@ class SocialRootSection : Routes.Route() {
friendCount: Int,
groupCount: Int,
searchActive: Boolean,
onSearchToggle: () -> Unit,
onPositioned: (Dp) -> Unit = {}
onSearchToggle: () -> Unit
) {
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)
val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
Column(
modifier = Modifier.headerHeightTracker(onPositioned),
verticalArrangement = Arrangement.spacedBy(12.dp)
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)
)
)
)
) {
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 = {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
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
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
)
}
}
)
if (tabSwitcherAlpha > 0.05f) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp)
.graphicsLayer {
alpha = tabSwitcherAlpha
translationY = (-10 * focusFactor).dp.toPx()
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 = { index ->
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
onTabSelected(index)
}
)
}
}
SocialTabSwitcher(
titles = titles,
pagerState = pagerState,
onTabSelected = onTabSelected
)
}
}
}
@@ -645,7 +631,7 @@ class SocialRootSection : Routes.Route() {
fontSize = 15.sp
)
Text(
text = translation["social_empty_hint"] ?: "Tap the + button to sync friends or groups.",
text = translation["social_empty_hint"],
color = PurrfectPalette.textSecondary,
fontSize = 12.sp
)

View File

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

View File

@@ -22,7 +22,6 @@ 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
@@ -33,6 +32,7 @@ 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,8 +50,6 @@ 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
@@ -71,10 +69,7 @@ class FriendTrackerCatalog : Routes.Route() {
override val translation by lazy { context.translation.getCategory("manager.friend_tracker_catalog") }
@Composable
private fun AvailableRulesTab(
controlsHeight: androidx.compose.ui.unit.Dp,
onScrollOffsetChanged: (Int) -> Unit
) {
private fun AvailableRulesTab(topBarHeight: Dp) {
val coroutineScope = rememberCoroutineScope()
val okHttpClient = remember { OkHttpClient() }
val gson = remember { context.gson }
@@ -83,6 +78,7 @@ 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() {
@@ -97,7 +93,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()
val req = Request.Builder().url(indexUrl).build() // ktlint-disable indent_wrapped_argument
okHttpClient.newCall(req).execute().use { response ->
if (response.isSuccessful) {
response.body?.charStream()?.let { reader ->
@@ -164,14 +160,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()
val req = Request.Builder().url(rawUrl).build() // ktlint-disable indent_wrapped_argument
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) {
if (content != null) { // ktlint-disable no-multi-spaces
withContext(Dispatchers.Main) {
routes.friendTrackerConfigJsonForImport = content
routes.friendTrackerConfigImport.navigate()
@@ -186,21 +182,27 @@ class FriendTrackerCatalog : Routes.Route() {
}
}
val listState = rememberLazyListState()
LaunchedEffect(listState.firstVisibleItemScrollOffset, listState.firstVisibleItemIndex) {
val offset = if (listState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else listState.firstVisibleItemScrollOffset
onScrollOffsetChanged(offset)
}
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 {
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
contentPadding = PaddingValues(
start = 8.dp,
top = controlsHeight,
top = topBarHeight + 12.dp,
end = 8.dp,
bottom = routes.bottomPadding
bottom = 8.dp + routes.bottomPadding
)
) {
item {
@@ -231,6 +233,7 @@ 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
@@ -324,34 +327,35 @@ class FriendTrackerCatalog : Routes.Route() {
}
}
}
}
}
override val content: @Composable (NavBackStackEntry) -> Unit = {
var scrollOffset by remember { mutableIntStateOf(0) }
val density = androidx.compose.ui.platform.LocalDensity.current
var controlsHeight by remember { mutableStateOf(100.dp) }
val density = LocalDensity.current
val statusBarTopPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
var topBarHeight by remember { mutableStateOf(statusBarTopPadding + 96.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,7 +25,13 @@ 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.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -47,12 +53,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() {
@@ -154,13 +160,8 @@ class FriendTrackerManagerRoot : Routes.Route() {
label: String,
icon: ImageVector,
onClick: () -> Unit,
modifier: Modifier = Modifier,
scrollOffset: Int = 0
modifier: Modifier = Modifier
) {
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(
@@ -184,25 +185,123 @@ class FriendTrackerManagerRoot : Routes.Route() {
.background(backgroundBrush, shape)
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(if (labelAlpha > 0.05f) 8.dp else 0.dp)
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
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
)
}
Icon(icon, contentDescription = label, tint = Color.White)
Text(label, color = Color.White, fontWeight = FontWeight.SemiBold)
}
}
}
override val topBarActions: @Composable RowScope.() -> Unit = {
// Handled via FloatingTopBar
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 }
)
}
}
private lateinit var activityLauncherHelper: ActivityLauncherHelper
@@ -248,18 +347,11 @@ class FriendTrackerManagerRoot : Routes.Route() {
}
@Composable
private fun ConfigRulesTab(scrollOffset: (Int) -> Unit) {
private fun ConfigRulesTab() {
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(
@@ -297,175 +389,178 @@ class FriendTrackerManagerRoot : Routes.Route() {
}
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
contentPadding = PaddingValues(bottom = routes.bottomPadding)
Column(
modifier = Modifier.fillMaxSize()
) {
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
LazyColumn(
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(bottom = routes.bottomPadding)
) {
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
}
val ruleShape = RoundedCornerShape(20.dp)
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable {
routes.editRule.navigate {
this["rule_id"] = rule.id.toString()
}
}
.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(
val ruleShape = RoundedCornerShape(20.dp)
Surface(
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))
) {
Box(
modifier = Modifier
.size(54.dp)
.background(
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.35f),
PurrfectPalette.glowSecondary.copy(alpha = 0.3f)
)
),
CircleShape
),
contentAlignment = Alignment.Center
) {
Icon(Icons.AutoMirrored.Filled.Rule, contentDescription = null, tint = Color.White)
.clickable {
routes.editRule.navigate {
this["rule_id"] = rule.id.toString()
}
}
}
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["scopes_suffix"])
}
},
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = PurrfectPalette.textSecondary
.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)
)
)
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,
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
.padding(horizontal = 10.dp, vertical = 6.dp)
)
}
}
}
}
}
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(6.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 = RoundedCornerShape(12.dp),
color = Color.White.copy(alpha = 0.06f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f))
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
),
contentAlignment = Alignment.Center
) {
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(
text = translation[if (enabled) "enabled_label" else "disabled_label"],
color = Color.White,
fontSize = 11.sp,
buildString {
append(eventCount)
append(" ")
append(translation["events_suffix"])
if (scopeCount > 0) {
append("")
append(scopeCount)
append(" ")
append(translation["scopes_suffix"])
}
},
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)
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,
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
.padding(horizontal = 10.dp, vertical = 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))
) {
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()
)
}
Switch(
checked = enabled,
onCheckedChange = {
enabled = it
context.database.setTrackerRuleState(rule.id, it)
},
colors = purrfectSwitchColors()
)
}
}
}
@@ -479,14 +574,10 @@ 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 ->
@@ -515,77 +606,125 @@ class FriendTrackerManagerRoot : Routes.Route() {
.background(PurrfectPalette.backgroundGradient)
) {
Column(modifier = Modifier.fillMaxSize()) {
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(
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp)
.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)
)
)
)
) {
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),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = text, color = Color.White, fontWeight = FontWeight.SemiBold)
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 }
)
}
}
}
}
}
}
HorizontalPager(
Spacer(modifier = Modifier.height(8.dp))
Surface(
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,
scrollOffset = { scrollOffset = it }
)
0 -> ConfigRulesTab(scrollOffset = { scrollOffset = it })
.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()
}
}
}
}
}
@@ -596,8 +735,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"], tint = Color.White) },
translation["individual_export_button"] to { Icon(Icons.Default.FileOpen, translation["individual_export_button"], tint = Color.White) }
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"]) }
),
onChoiceSelected = { index ->
showExportDialog = false
@@ -644,8 +783,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"], tint = Color.White) },
translation["individual_import_button"] to { Icon(Icons.Default.FileOpen, translation["individual_import_button"], tint = Color.White) }
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"]) }
),
onChoiceSelected = { index ->
showImportDialog = false
@@ -667,51 +806,33 @@ private fun SelectRuleDialog(
translation: me.eternal.purrfectsnap.common.bridge.wrapper.LocaleWrapper
) {
Dialog(onDismissRequest = onDismissRequest) {
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))
Card(
shape = RoundedCornerShape(16.dp),
) {
Column(
modifier = Modifier
.background(PurrfectPalette.cardOverlay, shape)
.padding(18.dp),
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
translation["manager.friend_tracker.select_rule_to_export_title"],
style = MaterialTheme.typography.headlineSmall,
color = Color.White,
fontWeight = FontWeight.ExtraBold,
textAlign = TextAlign.Center
)
Text(translation["manager.friend_tracker.select_rule_to_export_title"], style = MaterialTheme.typography.headlineSmall)
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.heightIn(max = 300.dp)
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(rules) { rule ->
Surface(
ElevatedCard(
onClick = { onRuleSelected(rule) },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
color = Color.White.copy(alpha = 0.06f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f))
modifier = Modifier.fillMaxWidth()
) {
Text(
text = rule.name,
modifier = Modifier.padding(16.dp),
fontWeight = FontWeight.SemiBold,
color = Color.White
fontWeight = FontWeight.SemiBold
)
}
}
}
TextButton(onClick = onDismissRequest) {
Text(translation["button.cancel"], color = PurrfectPalette.glowSecondary)
Text(translation["button.cancel"])
}
}
}
@@ -726,7 +847,7 @@ private fun ChoiceDialog(
onChoiceSelected: (Int) -> Unit
) {
Dialog(onDismissRequest = onDismissRequest) {
val shape = RoundedCornerShape(24.dp)
val shape = RoundedCornerShape(20.dp)
Surface(
shape = shape,
color = Color.Transparent,
@@ -744,9 +865,9 @@ private fun ChoiceDialog(
Column(
modifier = Modifier
.background(PurrfectPalette.cardOverlay, shape)
.padding(horizontal = 20.dp, vertical = 22.dp),
.padding(horizontal = 18.dp, vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp)
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = title,
@@ -755,24 +876,36 @@ private fun ChoiceDialog(
textAlign = TextAlign.Center
)
choices.forEachIndexed { index, (text, icon) ->
Surface(
SelectButton(
onClick = { onChoiceSelected(index) },
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)
}
}
text = text,
leadingIcon = icon
)
}
}
}
}
}
@Composable
private fun SelectButton(
onClick: () -> Unit,
text: String,
leadingIcon: @Composable (() -> Unit)? = null,
) {
OutlinedButton(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
if (leadingIcon != null) {
leadingIcon()
}
Text(text = text, modifier = Modifier.weight(1f))
}
}
}

View File

@@ -1,7 +1,5 @@
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
@@ -9,7 +7,6 @@ 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
@@ -64,7 +61,6 @@ 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") }
@@ -82,12 +78,6 @@ 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,
@@ -675,15 +665,13 @@ fun LogsTab(
)
}
}
}
}
LazyColumn(
modifier = Modifier.weight(1f),
state = listState,
contentPadding = PaddingValues(bottom = bottomPadding)
) {
}
}
LazyColumn(
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(bottom = bottomPadding)
) {
item {
Row(
modifier = Modifier

View File

@@ -73,7 +73,6 @@ 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() {
@@ -287,7 +286,10 @@ class ManageFriendTrackerReposSection: Routes.Route() {
onBack = { routes.navController.popBackStack() },
modifier = Modifier
.zIndex(2f)
.headerHeightTracker { topBarHeight = it }
.onGloballyPositioned {
val newHeight = with(density) { it.size.height.toDp() }
if (newHeight != topBarHeight) topBarHeight = newHeight
}
)
if (repositories.isEmpty()) {
Box(
@@ -307,9 +309,9 @@ class ManageFriendTrackerReposSection: Routes.Route() {
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 12.dp,
top = topBarHeight,
top = topBarHeight + 12.dp,
end = 12.dp,
bottom = routes.bottomPadding
bottom = 18.dp + routes.bottomPadding
),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {

View File

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

View File

@@ -1,6 +1,5 @@
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
@@ -105,19 +104,7 @@ class SaveFolderScreen : SetupScreen() {
color = PurrfectPalette.textSecondary
)
Text(
text = if (currentFolder.isBlank()) {
context.translation["setup.save_folder.system_default_label"]
} else {
runCatching {
val decoded = Uri.decode(currentFolder)
val friendlyPath = if (decoded.contains(":")) {
decoded.substringAfterLast(":")
} else {
decoded.substringAfterLast("/")
}
friendlyPath.trim('/').takeIf { it.isNotBlank() } ?: decoded
}.getOrDefault(currentFolder)
},
text = if (currentFolder.isBlank()) context.translation["setup.save_folder.system_default_label"] else currentFolder,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White,

View File

@@ -2,16 +2,27 @@ package me.eternal.purrfectsnap.ui.util
import android.content.Context
import android.provider.Settings
import androidx.compose.animation.core.*
import androidx.compose.animation.core.Easing
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.runtime.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
/**
* Returns true if the user has disabled animator duration scale at the system level.
* This respects Accessibility/Developer settings where motion is reduced or disabled.
*/
fun prefersReducedMotion(context: Context): Boolean {
return runCatching {
@@ -26,8 +37,9 @@ fun prefersReducedMotion(context: Context): Boolean {
@Composable
fun rememberPrefersReducedMotion(): Boolean {
val context = LocalContext.current
val context = androidx.compose.ui.platform.LocalContext.current
val state = remember { mutableStateOf(prefersReducedMotion(context)) }
// One-shot read; if you need to observe changes live, add a ContentObserver.
LaunchedEffect(Unit) {
state.value = prefersReducedMotion(context)
}
@@ -35,34 +47,18 @@ fun rememberPrefersReducedMotion(): Boolean {
}
object Motion {
/**
* Standard scroll distance (in pixels) for the header to complete its morphing animation.
*/
const val HEADER_MORPH_THRESHOLD = 300f
/**
* Milliseconds per pixel for marquee scrolling speed.
*/
const val MARQUEE_CADENCE = 20
// 1. High-end Spring Physics for "Alive" UI
val springDynamic = spring<Float>(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessMediumLow
)
@Composable
fun tweenSpec(durationMillis: Int, easing: Easing = FastOutSlowInEasing): FiniteAnimationSpec<Int> {
fun tweenSpec(durationMillis: Int, easing: Easing = androidx.compose.animation.core.FastOutSlowInEasing): FiniteAnimationSpec<Int> {
val reduced = rememberPrefersReducedMotion()
val d = if (reduced) 0 else durationMillis
return tween(durationMillis = d, easing = easing)
return tween<Int>(durationMillis = d, easing = easing)
}
@Composable
fun tweenFloatSpec(durationMillis: Int, easing: Easing = FastOutSlowInEasing): FiniteAnimationSpec<Float> {
fun tweenFloatSpec(durationMillis: Int, easing: Easing = androidx.compose.animation.core.FastOutSlowInEasing): FiniteAnimationSpec<Float> {
val reduced = rememberPrefersReducedMotion()
val d = if (reduced) 0 else durationMillis
return tween(durationMillis = d, easing = easing)
return tween<Float>(durationMillis = d, easing = easing)
}
@Composable
@@ -72,23 +68,23 @@ object Motion {
}
/**
* Kinetic scale-down on press using spring physics.
* Apply a subtle scale-down on press for clickable components (cards, buttons, tiles).
* Pass the same [interactionSource] into the clickable component to synchronize state.
*/
@Composable
fun Modifier.scaleOnPress(
interactionSource: InteractionSource,
enabled: Boolean = true,
scaleDown: Float = 0.96f
scaleDown: Float = 0.98f
): Modifier {
val pressed by interactionSource.collectIsPressedAsState()
val target = if (pressed) scaleDown else 1f
val animated by animateFloatAsState(
val spec = Motion.tweenFloatSpec(150)
val animated by androidx.compose.animation.core.animateFloatAsState(
targetValue = target,
animationSpec = Motion.springDynamic,
animationSpec = spec,
label = "pressScale"
)
return this.then(Modifier.graphicsLayer {
scaleX = animated
scaleY = animated

View File

@@ -1,26 +0,0 @@
package me.eternal.purrfectsnap.ui.util
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* A specialized modifier that tracks the height of the header (FloatingTopBar)
* and provides it to the caller. This eliminates repetitive onGloballyPositioned
* logic across all sub-pages.
*
* @param onHeightChanged Callback triggered when the header height is measured.
*/
fun Modifier.headerHeightTracker(
onHeightChanged: (Dp) -> Unit
): Modifier = composed {
val density = LocalDensity.current
this.onGloballyPositioned { coordinates ->
val heightDp = with(density) { coordinates.size.height.toDp() }
onHeightChanged(heightDp)
}
}

View File

@@ -1,56 +0,0 @@
package me.eternal.purrfectsnap.ui.util
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.basicMarquee
import androidx.compose.ui.text.rememberTextMeasurer
/**
* A premium marquee text component that supports continuous looping.
* Optimized using TextMeasurer for robust width calculation.
*/
@Composable
fun PurrfectMarqueeText(
text: String,
style: TextStyle = TextStyle.Default,
modifier: Modifier = Modifier,
color: Color = Color.White,
textAlign: TextAlign = TextAlign.Center,
contentAlignment: Alignment = Alignment.Center,
delayMillis: Int = 1500,
maxLines: Int = 1,
enabled: Boolean = true
) {
Box(
modifier = modifier.clipToBounds(),
contentAlignment = contentAlignment
) {
Text(
text = text,
style = style,
color = color,
textAlign = textAlign,
maxLines = maxLines,
softWrap = true,
overflow = TextOverflow.Ellipsis,
modifier = if (enabled) {
Modifier.basicMarquee(
iterations = Int.MAX_VALUE,
repeatDelayMillis = delayMillis,
initialDelayMillis = delayMillis,
velocity = 40.dp
)
} else Modifier
)
}
}

View File

@@ -0,0 +1,26 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:width="48dp"
android:height="48dp"
android:viewportWidth="1000"
android:viewportHeight="1000">
<group
android:scaleX="0.65"
android:scaleY="0.65"
android:translateX="175"
android:translateY="175">
<path
android:fillColor="#ffffffff"
android:pathData="m397.9,491.5h-55.1c-10.1,0 -18.4,8.2 -18.4,18.4h0c0,10.1 8.2,18.4 18.4,18.4h55.1c15.3,0 27.8,13.7 27.8,30.6s-12.5,30.6 -27.8,30.6h-55.1c-10.1,0 -18.4,8.2 -18.4,18.4h0c0,10.1 8.2,18.4 18.4,18.4h55.1c33.8,0 61.2,-30.1 61.2,-67.3s-27.4,-67.3 -61.2,-67.3Z"/>
<path
android:fillColor="#ffffffff"
android:pathData="m814.2,491.5h-62.6v-49.7h-0.1c0,-2 0.1,-4.1 0.1,-6.1 0,-152.1 -123.3,-275.5 -275.5,-275.5s-275.5,123.3 -275.5,275.5c0,2 0,4.1 0.1,6.1h-0.1v302.1c0,29.6 13.5,57.6 36.7,76l11.9,9.4c18.3,13.9 47,13.8 65.2,0l18.5,-14c7.1,-5.4 21,-5.4 28.1,0l18.5,14c18.3,13.9 46.9,13.9 65.2,0l18.5,-14c3.3,-2.5 8.2,-3.8 13.1,-4 4.9,0.2 9.7,1.5 13,4l18.5,14c18.3,13.8 46.8,13.8 65.1,0l18.5,-14c7.1,-5.4 20.9,-5.4 28,0l18.5,14c18.2,13.8 46.8,13.8 65.1,0l11.9,-9.4c23.2,-18.4 36.7,-46.4 36.7,-75.9v-117.8h62.6c33.8,0 61.2,-30.1 61.2,-67.3s-27.4,-67.3 -61.2,-67.3ZM714.8,441.9v310.2c0,16.4 -7.7,31.9 -20.7,41.8l-9.6,7.3c-7.1,5.4 -20.9,5.4 -28,0l-18.5,-14c-18.2,-13.8 -46.8,-13.8 -65.1,0l-18.5,14c-7.1,5.4 -20.9,5.4 -28,0l-18.5,-14c-8.8,-6.7 -20.1,-10.1 -31.4,-10.3v-0c-0.1,0 -0.1,0 -0.2,0 -0,0 -0.1,0 -0.1,0h0c-11.4,0.2 -22.6,3.7 -31.5,10.4l-18.5,14c-7.1,5.4 -21,5.4 -28.1,0l-18.5,-14c-18.3,-13.8 -46.9,-13.8 -65.2,0l-18.5,14c-7.1,5.4 -21,5.4 -28,0l-9.7,-7.4c-13.1,-9.9 -20.8,-25.4 -20.8,-41.8v-310.1h0.1c-0.1,-2 -0.1,-4.1 -0.1,-6.1 0,-131.9 106.9,-238.7 238.7,-238.7s238.7,106.9 238.7,238.7c0,2 -0,4.1 -0.1,6.1h0.1ZM814.2,589.5h-62.6v-61.2h62.6c15.3,0 27.8,13.7 27.8,30.6s-12.5,30.6 -27.8,30.6Z"
tools:ignore="VectorPath" />
<path
android:fillColor="#ffffffff"
android:pathData="m711.1,336.1c-6.6,-1.6 -10.6,-1.7 -17.4,-2.8 -33.1,-5.4 -65.4,-0.8 -97.3,8.2 -7,2 -13.9,3.4 -20.9,3.5 -7,-0.2 -13.9,-1.5 -20.9,-3.5 -31.9,-9 -64.2,-13.6 -97.3,-8.2 -6.8,1.1 -10.8,1.1 -17.4,2.8 -6,1.5 -7.5,11.2 -5.5,29.1 0.9,8.2 3.3,16.1 4.9,24.2 1.6,8.1 3.6,16.1 7.9,23.3 4.1,6.8 10.1,11.6 17.5,13.9 16.9,5.4 34.1,6.2 51.3,1.4 14.5,-4.1 26.1,-12.6 33.4,-25.9 5.4,-9.9 9.7,-20.4 14.5,-30.7 0.7,-1.6 1.3,-3.2 2.1,-4.8 2.2,-4.4 5,-6.3 9.6,-6.3 4.5,-0 7.3,1.8 9.6,6.3 0.8,1.6 1.4,3.2 2.1,4.8 4.8,10.3 9.1,20.8 14.5,30.7 7.2,13.4 18.9,21.9 33.4,25.9 17.1,4.8 34.4,4 51.3,-1.4 7.4,-2.3 13.4,-7.1 17.5,-13.9 4.3,-7.2 6.3,-15.1 7.9,-23.3 1.5,-8.1 4,-16 4.9,-24.2 2,-17.9 0.5,-27.6 -5.5,-29.1Z"/>
</group>
</vector>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 162 KiB

View File

@@ -3237,7 +3237,7 @@
"username": "ইউজারনেম",
"user_id": "ইউজার আইডি",
"posted_on": "পোস্ট করা হয়েছে",
"loading_username": "লোড হচ্ছে\u2026",
"loading_username": "লোড হচ্ছে",
"username_copied": "ইউজারনেম কপি হয়েছে",
"user_id_copied": "ইউজার আইডি কপি হয়েছে",
"friend_status": "বন্ধু স্ট্যাটাস",

View File

@@ -247,7 +247,7 @@
"about_tagline": "Ein Xposed-Modul, um dein Snapchat-Erlebnis zu verbessern!",
"about_lead_developers_title": "Hauptentwickler",
"about_story_title": "Unsere Geschichte",
"about_story": "PurrfectSnap wurde am 2. Oktober 2025 als Fork von SnapEnhance durch ΞTΞRNAL gegründet, mit der Vision, Nutzern das hochwertige Snapchat-Erlebnis zu bieten, das sie verdienen. Diese App sollte eigentlich nur ein kleines Update im SnapEnhance-Repository sein, wurde aber bald zu einer eigenständigen App, in der die Mitwirkenden immer mehr Funktionen hinzufügten. Dann trat der Entwickler <RSR/> dem Team bei und die App wurde bald ein großer Erfolg.\n\nWir erhielten viel Liebe und Unterstützung und erreichten 1K+ Downloads in nur zwei Tagen! Wir danken allen Nutzern und Mitwirkenden; ohne eure Unterstützung hätten wir diesen Punkt nicht erreicht.\n\nWir möchten auch rhunk, dem Hauptentwickler von SnapEnhance, großen Dank aussprechen, denn ohne ihn würde diese App nicht einmal existieren. Wir sind ihm unendlich dankbar.\n\nSchließlich möchten wir all unseren Admins danken, insbesondere: CLASSIC GENIUS, Harry, Sujal, Zain & schrodingerspet, die von Anfang an bei uns waren. Wir danken auch allen Testern, insbesondere Leo & Toxic, die kontinuierlich getestet und Fehler gemeldet haben. Wir sind unendlich dankbar für euren Beitrag.",
"about_story": "PurrfectSnap wurde am 2. Oktober 2025 als Fork von SnapEnhance durch ΞTΞRNAL gegründet, mit der Vision, Nutzern das hochwertige Snapchat-Erlebnis zu bieten, das sie verdienen. Diese App sollte eigentlich nur ein kleines Update im SnapEnhance-Repository sein, wurde aber bald zu einer eigenständigen App, in der die Mitwirkenden immer mehr Funktionen hinzufügten. Dann trat der Entwickler <RSR/> dem Team bei und die App wurde bald ein großer Erfolg. Wir erhielten viel Liebe und Unterstützung und erreichten 1K+ Downloads in nur zwei Tagen! Wir danken allen Nutzern und Mitwirkenden; ohne eure Unterstützung hätten wir diesen Punkt nicht erreicht. Wir möchten auch rhunk, dem Hauptentwickler von SnapEnhance, großen Dank aussprechen, denn ohne ihn würde diese App nicht einmal existieren. Wir sind ihm unendlich dankbar. Schließlich möchten wir all unseren Admins danken, insbesondere: CLASSIC GENIUS, Harry, Sujal, Zain & schrodingerspet, die von Anfang an bei uns waren. Wir danken auch allen Testern, insbesondere Leo & Toxic, die kontinuierlich getestet und Fehler gemeldet haben. Wir sind unendlich dankbar für euren Beitrag.",
"about_thanks_title": "Mit Liebe, PurrfectSnap Team",
"about_magic_toast": "Tippe 5 Mal auf diesen Bildschirm, um etwas Magie zu sehen 😉!",
"github_button": "GitHub",
@@ -340,7 +340,6 @@
"remove_selected_tasks_title": "Möchtest du die ausgewählten Aufgaben wirklich entfernen?",
"remove_all_tasks_title": "Möchtest du wirklich alle Aufgaben entfernen?",
"delete_files_option": "Auch Dateien löschen",
"delete_files_option_hint": "Zugehörige Downloads dauerhaft entfernen",
"remove_selected_tasks_confirm": "{count} Aufgaben entfernen?",
"remove_all_tasks_confirm": "Alle Aufgaben entfernen?"
},

View File

@@ -247,7 +247,7 @@
"about_tagline": "An Xposed Module meant to enhance your Snapchat experience!",
"about_lead_developers_title": "Lead Developers",
"about_story_title": "Our Story",
"about_story": "PurrfectSnap was founded on 2nd of October, 2025, as a fork of SnapEnhance by ΞTΞRNAL with a vision to provide users the quality Snapchat experience they deserve. This app was just meant to be a minor update in the SnapEnhance repository, but it soon became a separate app wherein the contributors kept adding features. Then the developer <RSR/> joined the team, and this app soon became a huge success.\n\nWe would also like to convey our huge thanks to rhunk, the lead developer of SnapEnhance, as without him, this app wouldn't even exist. We are immensely grateful to him.\n\nWe received much love and support and gained 1K+ downloads in just two days! We thank all users and contributors; without your support, we wouldn't have reached this place.\n\nLastly, we would like to thank all our admins, notably: CLASSIC GENIUS, Harry, Sujal, Zain & schrodingerspet, who were right there with us from the very beginning. We would also like to thank all testers, notably Leo & Toxic, who tested and reported bugs continuously. We are immensely grateful for your contribution.",
"about_story": "PurrfectSnap was founded on 2nd of October, 2025, as a fork of SnapEnhance by ΞTΞRNAL with a vision to provide users the quality Snapchat experience they deserve. This app was just meant to be a minor update in the SnapEnhance repository, but it soon became a separate app wherein the contributors kept adding features. Then the developer <RSR/> joined the team, and this app soon became a huge success. We received much love and support and gained 1K+ downloads in just two days! We thank all users and contributors; without your support, we wouldn't have reached this place. We would also like to convey our huge thanks to rhunk, the lead developer of SnapEnhance, as without him, this app wouldn't even exist. We are immensely grateful to him. Lastly, we would like to thank all our admins, notably: CLASSIC GENIUS, Harry, Sujal, Zain & schrodingerspet, who were right there with us from the very beginning. We would also like to thank all testers, notably Leo & Toxic, who tested and reported bugs continuously. We are immensely grateful for your contribution.",
"about_thanks_title": "With love, PurrfectSnap Team",
"about_magic_toast": "Tap 5 times in this screen to see some magic 😉!",
"github_button": "GitHub",
@@ -3236,7 +3236,7 @@
"username": "Username",
"user_id": "User ID",
"posted_on": "Posted",
"loading_username": "Loading\u2026",
"loading_username": "Loading",
"username_copied": "Username copied",
"user_id_copied": "User ID copied",
"friend_status": "Friend status",

View File

@@ -207,7 +207,6 @@
"update_ready_label": "Ready to install",
"purr_aura_active_label": "PurrAura Active!",
"purr_aura_inactive_label": "PurrAura Inactive",
"about_meet_team_button": "About Us",
"open_settings_button": "Open Settings",
"wiki_button": "Wiki",
"github_button": "GitHub",
@@ -247,12 +246,10 @@
"about_title": "PurrfectSnap",
"about_tagline": "An Xposed Module meant to enhance your Snapchat experience!",
"about_lead_developers_title": "Lead Developers",
"about_dev_external": "ΞTΞRNAL",
"about_dev_rsr": "<RSR/>",
"about_story_title": "Our Story",
"about_story": "PurrfectSnap was founded on 2nd of October, 2025, as a fork of SnapEnhance by ΞTΞRNAL with a vision to provide users the quality Snapchat experience they deserve. This app was just meant to be a minor update in the SnapEnhance repository, but it soon became a separate app wherein the contributors kept adding features. Then the developer <RSR/> joined the team, and this app soon became a huge success.\n\nWe would also like to convey our huge thanks to rhunk, the lead developer of SnapEnhance, as without him, this app wouldn't even exist. We are immensely grateful to him.\n\nWe received much love and support and gained 1K+ downloads in just two days! We thank all users and contributors; without your support, we wouldn't have reached this place.\n\nLastly, we would like to thank all our admins, notably: CLASSIC GENIUS, Harry, Sujal, Zain & schrodingerspet, who were right there with us from the very beginning. We would also like to thank all testers, notably Leo & Toxic, who tested and reported bugs continuously. We are immensely grateful for your contribution.",
"about_story": "PurrfectSnap was founded on 2nd of October, 2025, as a fork of SnapEnhance by ΞTΞRNAL with a vision to provide users the quality Snapchat experience they deserve. This app was just meant to be a minor update in the SnapEnhance repository, but it soon became a separate app wherein the contributors kept adding features. Then the developer <RSR/> joined the team, and this app soon became a huge success. We received much love and support and gained 1K+ downloads in just two days! We thank all users and contributors; without your support, we wouldn't have reached this place. We would also like to convey our huge thanks to rhunk, the lead developer of SnapEnhance, as without him, this app wouldn't even exist. We are immensely grateful to him. Lastly, we would like to thank all our admins, notably: CLASSIC GENIUS, Harry, Sujal, Zain & schrodingerspet, who were right there with us from the very beginning. We would also like to thank all testers, notably Leo & Toxic, who tested and reported bugs continuously. We are immensely grateful for your contribution.",
"about_thanks_title": "With love, PurrfectSnap Team",
"about_magic_toast": "Tap 5 times in this screen to see some magic \ud83d\ude09!",
"about_magic_toast": "Tap 5 times in this screen to see some magic 😉!",
"github_button": "GitHub",
"telegram_button": "Telegram"
},
@@ -343,7 +340,6 @@
"remove_selected_tasks_title": "Are you sure you want to remove selected tasks?",
"remove_all_tasks_title": "Are you sure you want to remove all tasks?",
"delete_files_option": "Also delete files",
"delete_files_option_hint": "Permanently remove associated downloads",
"remove_selected_tasks_confirm": "Remove {count} tasks?",
"remove_all_tasks_confirm": "Remove all tasks?"
},
@@ -352,8 +348,6 @@
"export_option": "Export",
"import_option": "Import",
"reset_option": "Reset",
"include_saved_locations": "Include Saved Locations",
"include_saved_locations_description": "Export your saved location coordinates",
"config_export_success_toast": "Config exported successfully",
"config_import_success_toast": "Config imported successfully",
"config_import_failure_toast": "Failed to import config {error}",
@@ -416,15 +410,7 @@
"streaks_expiration_text_expired": "Expired",
"reminder_button": "Set Reminder",
"delete_scope_confirm_dialog_title": "Are you sure you want to delete a {scope}?",
"notes_placeholder": "Click to add a note",
"eta_day": "{count} day",
"eta_days": "{count} days",
"eta_hour": "{count} hour",
"eta_hours": "{count} hours",
"eta_minute": "{count} minute",
"eta_minutes": "{count} minutes",
"eta_second": "{count} second",
"eta_seconds": "{count} seconds"
"notes_placeholder": "Click to add a note"
},
"logged_stories": {
"story_failed_to_load": "Failed to load",
@@ -3257,7 +3243,7 @@
"username": "Username",
"user_id": "User ID",
"posted_on": "Posted",
"loading_username": "Loading\u2026",
"loading_username": "Loading",
"username_copied": "Username copied",
"user_id_copied": "User ID copied",
"friend_status": "Friend status",
@@ -3660,5 +3646,3 @@
"openrouter": "OpenRouter"
}
}

View File

@@ -247,7 +247,7 @@
"about_tagline": "¡Un Módulo Xposed destinado a mejorar tu experiencia en Snapchat!",
"about_lead_developers_title": "Desarrolladores Principales",
"about_story_title": "Nuestra Historia",
"about_story": "PurrfectSnap fue fundado el 2 de octubre de 2025, como un fork de SnapEnhance por ΞTΞRNAL con la visión de proporcionar a los usuarios la experiencia de calidad en Snapchat que merecen. Esta aplicación solo estaba destinada a ser una actualización menor en el repositorio de SnapEnhance, pero pronto se convirtió en una aplicación separada donde los colaboradores siguieron añadiendo funciones. Luego, el desarrollador <RSR/> se unió al equipo, y esta aplicación pronto se convirtió en un gran éxito.\n\nTambién nos gustaría expresar nuestro enorme agradecimiento a rhunk, el desarrollador principal de SnapEnhance, ya que sin él, esta aplicación ni siquiera existiría. Le estamos inmensamente agradecidos.\n\n¡Recibimos mucho amor y apoyo y ganamos más de 1K descargas en solo dos días! Agradecemos a todos los usuarios y colaboradores; sin su apoyo, no habríamos llegado a este lugar.\n\nPor último, nos gustaría agradecer a todos nuestros administradores, notablemente: CLASSIC GENIUS, Harry, Sujal, Zain & schrodingerspet, que estuvieron allí con nosotros desde el principio. También nos gustaría agradecer a todos los testers, notablemente Leo & Toxic, que probaron e informaron errores continuamente. Estamos inmensamente agradecidos por su contribución.",
"about_story": "PurrfectSnap fue fundado el 2 de octubre de 2025, como un fork de SnapEnhance por ΞTΞRNAL con la visión de proporcionar a los usuarios la experiencia de calidad en Snapchat que merecen. Esta aplicación solo estaba destinada a ser una actualización menor en el repositorio de SnapEnhance, pero pronto se convirtió en una aplicación separada donde los colaboradores siguieron añadiendo funciones. Luego, el desarrollador <RSR/> se unió al equipo, y esta aplicación pronto se convirtió en un gran éxito. ¡Recibimos mucho amor y apoyo y ganamos más de 1K descargas en solo dos días! Agradecemos a todos los usuarios y colaboradores; sin su apoyo, no habríamos llegado a este lugar. También nos gustaría expresar nuestro enorme agradecimiento a rhunk, el desarrollador principal de SnapEnhance, ya que sin él, esta aplicación ni siquiera existiría. Le estamos inmensamente agradecidos. Por último, nos gustaría agradecer a todos nuestros administradores, notablemente: CLASSIC GENIUS, Harry, Sujal, Zain & schrodingerspet, que estuvieron allí con nosotros desde el principio. También nos gustaría agradecer a todos los testers, notablemente Leo & Toxic, que probaron e informaron errores continuamente. Estamos inmensamente agradecidos por su contribución.",
"about_thanks_title": "Con amor, el equipo de PurrfectSnap",
"about_magic_toast": "¡Toca 5 veces en esta pantalla para ver algo de magia 😉!",
"github_button": "GitHub",
@@ -3236,7 +3236,7 @@
"username": "Nombre de usuario",
"user_id": "ID de Usuario",
"posted_on": "Publicado",
"loading_username": "Cargando\u2026",
"loading_username": "Cargando",
"username_copied": "Nombre de usuario copiado",
"user_id_copied": "ID de usuario copiado",
"friend_status": "Estado de amigo",

View File

@@ -247,7 +247,7 @@
"about_tagline": "Un module Xposed conçu pour améliorer votre expérience Snapchat !",
"about_lead_developers_title": "Développeurs principaux",
"about_story_title": "Notre histoire",
"about_story": "PurrfectSnap a été fondé le 2 octobre 2025, en tant que fork de SnapEnhance par ΞTΞRNAL avec la vision d'offrir aux utilisateurs l'expérience Snapchat de qualité qu'ils méritent. Cette application ne devait être qu'une mise à jour mineure du dépôt SnapEnhance, mais elle est rapidement devenue une application distincte où les contributeurs ont continué à ajouter des fonctionnalités. Ensuite, le développeur <RSR/> a rejoint l'équipe, et cette application est rapidement devenue un énorme succès.\n\nNous avons reçu beaucoup d'amour et de soutien et avons gagné plus de 1000 téléchargements en seulement deux jours ! Nous remercions tous les utilisateurs et contributeurs ; sans votre soutien, nous n'aurions pas atteint ce stade.\n\nNous tenons également à remercier chaleureusement rhunk, le développeur principal de SnapEnhance, car sans lui, cette application n'existerait même pas. Nous lui sommes immensément reconnaissants.\n\nEnfin, nous tenons à remercier tous nos administrateurs, notamment : CLASSIC GENIUS, Harry, Sujal, Zain & schrodingerspet, qui étaient là avec nous dès le tout début. Nous tenons également à remercier tous les testeurs, notamment Leo & Toxic, qui ont testé et signalé les bugs en continu. Nous sommes immensément reconnaissants pour votre contribution.",
"about_story": "PurrfectSnap a été fondé le 2 octobre 2025, en tant que fork de SnapEnhance par ΞTΞRNAL avec la vision d'offrir aux utilisateurs l'expérience Snapchat de qualité qu'ils méritent. Cette application ne devait être qu'une mise à jour mineure du dépôt SnapEnhance, mais elle est rapidement devenue une application distincte où les contributeurs ont continué à ajouter des fonctionnalités. Ensuite, le développeur <RSR/> a rejoint l'équipe, et cette application est rapidement devenue un énorme succès. Nous avons reçu beaucoup d'amour et de soutien et avons gagné plus de 1000 téléchargements en seulement deux jours ! Nous remercions tous les utilisateurs et contributeurs ; sans votre soutien, nous n'aurions pas atteint ce stade. Nous tenons également à remercier chaleureusement rhunk, le développeur principal de SnapEnhance, car sans lui, cette application n'existerait même pas. Nous lui sommes immensément reconnaissants. Enfin, nous tenons à remercier tous nos administrateurs, notamment : CLASSIC GENIUS, Harry, Sujal, Zain & schrodingerspet, qui étaient là avec nous dès le tout début. Nous tenons également à remercier tous les testeurs, notamment Leo & Toxic, qui ont testé et signalé les bugs en continu. Nous sommes immensément reconnaissants pour votre contribution.",
"about_thanks_title": "Avec amour, l'équipe PurrfectSnap",
"about_magic_toast": "Appuyez 5 fois sur cet écran pour voir un peu de magie 😉 !",
"github_button": "GitHub",

View File

@@ -3236,7 +3236,7 @@
"username": "Felhasználónév",
"user_id": "Felhasználói azonosító",
"posted_on": "Közzétéve",
"loading_username": "Betöltés\u2026",
"loading_username": "Betöltés",
"username_copied": "Felhasználónév másolva",
"user_id_copied": "Felhasználói azonosító másolva",
"friend_status": "Barát státusz",

View File

@@ -3236,7 +3236,7 @@
"username": "Username",
"user_id": "ID Utente",
"posted_on": "Pubblicato",
"loading_username": "Caricamento\u2026",
"loading_username": "Caricamento",
"username_copied": "Username copiato",
"user_id_copied": "ID Utente copiato",
"friend_status": "Stato amicizia",

View File

@@ -3236,7 +3236,7 @@
"username": "ユーザー名",
"user_id": "ユーザーID",
"posted_on": "投稿日",
"loading_username": "読み込み中\u2026",
"loading_username": "読み込み中",
"username_copied": "ユーザー名をコピーしました",
"user_id_copied": "ユーザーIDをコピーしました",
"friend_status": "フレンドステータス",

View File

@@ -3237,7 +3237,7 @@
"username": "사용자 이름",
"user_id": "사용자 ID",
"posted_on": "게시일",
"loading_username": "로드 중\u2026",
"loading_username": "로드 중",
"username_copied": "사용자 이름 복사됨",
"user_id_copied": "사용자 ID 복사됨",
"friend_status": "친구 상태",

View File

@@ -3236,7 +3236,7 @@
"username": "Gebruikersnaam",
"user_id": "Gebruikers-ID",
"posted_on": "Geplaatst",
"loading_username": "Laden\u2026",
"loading_username": "Laden",
"username_copied": "Gebruikersnaam gekopieerd",
"user_id_copied": "Gebruikers-ID gekopieerd",
"friend_status": "Vriendstatus",