Revert "Feature: UI Modernization, Themed Icons, and Performance Optimization"
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
) {
|
||||
|
||||
@@ -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) = {}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
) {
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
26
app/src/main/res/drawable/launcher_icon_monochrome.xml
Normal 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>
|
||||
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 162 KiB |
@@ -3237,7 +3237,7 @@
|
||||
"username": "ইউজারনেম",
|
||||
"user_id": "ইউজার আইডি",
|
||||
"posted_on": "পোস্ট করা হয়েছে",
|
||||
"loading_username": "লোড হচ্ছে\u2026",
|
||||
"loading_username": "লোড হচ্ছে…",
|
||||
"username_copied": "ইউজারনেম কপি হয়েছে",
|
||||
"user_id_copied": "ইউজার আইডি কপি হয়েছে",
|
||||
"friend_status": "বন্ধু স্ট্যাটাস",
|
||||
|
||||
@@ -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?"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3236,7 +3236,7 @@
|
||||
"username": "ユーザー名",
|
||||
"user_id": "ユーザーID",
|
||||
"posted_on": "投稿日",
|
||||
"loading_username": "読み込み中\u2026",
|
||||
"loading_username": "読み込み中…",
|
||||
"username_copied": "ユーザー名をコピーしました",
|
||||
"user_id_copied": "ユーザーIDをコピーしました",
|
||||
"friend_status": "フレンドステータス",
|
||||
|
||||
@@ -3237,7 +3237,7 @@
|
||||
"username": "사용자 이름",
|
||||
"user_id": "사용자 ID",
|
||||
"posted_on": "게시일",
|
||||
"loading_username": "로드 중\u2026",
|
||||
"loading_username": "로드 중…",
|
||||
"username_copied": "사용자 이름 복사됨",
|
||||
"user_id_copied": "사용자 ID 복사됨",
|
||||
"friend_status": "친구 상태",
|
||||
|
||||
@@ -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",
|
||||
|
||||