diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/task/AnnouncementCheckWorker.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/task/AnnouncementCheckWorker.kt index c1ad62ec..9ea9018f 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/task/AnnouncementCheckWorker.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/task/AnnouncementCheckWorker.kt @@ -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) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/task/UpdateCheckWorker.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/task/UpdateCheckWorker.kt index 33403a6e..0bf9c075 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/task/UpdateCheckWorker.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/task/UpdateCheckWorker.kt @@ -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) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/MainActivity.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/MainActivity.kt index b7145dfa..8c4bb724 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/MainActivity.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/MainActivity.kt @@ -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() diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/Navigation.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/Navigation.kt index 6175515f..5ff628f3 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/Navigation.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/Navigation.kt @@ -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 { @@ -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) } - diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/components/FloatingTopBar.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/components/FloatingTopBar.kt index 4efea7cd..c7bdb76c 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/components/FloatingTopBar.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/components/FloatingTopBar.kt @@ -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 + ) } } } diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/data/Updater.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/data/Updater.kt index 9669cfc4..0d498a3d 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/data/Updater.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/data/Updater.kt @@ -14,7 +14,6 @@ object Updater { val versionName: String, val releaseUrl: String, val workflowId: Long?, - val body: String? = null, val assetDownloads: Map = 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>>() + private val cache = mutableMapOf() 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() } } diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/FileImportsRoot.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/FileImportsRoot.kt index aac6944d..f19008cf 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/FileImportsRoot.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/FileImportsRoot.kt @@ -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() } + } ) } } diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/LoggerHistoryRoot.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/LoggerHistoryRoot.kt index 72fe4118..f1e4f88c 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/LoggerHistoryRoot.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/LoggerHistoryRoot.kt @@ -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() } - 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()) { - 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()) { + 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() } - - 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() } + + 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 } - ) + } + } } } } diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/ManageReposSection.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/ManageReposSection.kt index 8cbc3871..d8f3535e 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/ManageReposSection.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/ManageReposSection.kt @@ -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 } - ) } } } diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/TasksRootSection.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/TasksRootSection.kt index 36f422db..90f5567e 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/TasksRootSection.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/TasksRootSection.kt @@ -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 }, diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/ConfigExportSummaryScreen.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/ConfigExportSummaryScreen.kt index 24a3678a..5edc1c4e 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/ConfigExportSummaryScreen.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/ConfigExportSummaryScreen.kt @@ -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() } - 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) - } - } - ) } } diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/ConfigImportConfirmationScreen.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/ConfigImportConfirmationScreen.kt index ec43d95d..c06bd7c1 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/ConfigImportConfirmationScreen.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/ConfigImportConfirmationScreen.kt @@ -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() } 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) - } - } - ) + } + } +} } } diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt index 61e1a469..762f191e 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt @@ -1,7 +1,5 @@ package me.eternal.purrfectsnap.ui.manager.pages.features -import me.eternal.purrfectsnap.ui.util.Motion - import android.net.Uri import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.core.tween @@ -11,7 +9,28 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource -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.RowScope +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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.items @@ -32,7 +51,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor @@ -40,7 +61,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import me.eternal.purrfectsnap.ui.util.headerHeightTracker +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight @@ -61,6 +82,7 @@ import androidx.navigation.NavOptions import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import me.eternal.purrfectsnap.common.config.* @@ -71,6 +93,8 @@ import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList import me.eternal.purrfectsnap.ui.manager.Routes import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette import me.eternal.purrfectsnap.ui.util.* +import org.json.JSONArray +import org.json.JSONObject import kotlin.math.max import kotlin.math.min @@ -88,7 +112,7 @@ class FeaturesRootSection : Routes.Route() { } ?: routeInfo.translatedKey?.value } SEARCH_FEATURE_ROUTE -> { - translation["search_button"] ?: "Search" + translation["search_button"] } else -> { routeInfo.translatedKey?.value @@ -110,7 +134,10 @@ class FeaturesRootSection : Routes.Route() { val containers = mutableMapOf>() fun queryContainerRecursive(container: ConfigContainer) { container.properties.forEach { - if (it.key.dataType.type == DataProcessors.Type.CONTAINER && !it.key.params.flags.contains(ConfigFlag.HIDDEN)) { + if ( + it.key.dataType.type == DataProcessors.Type.CONTAINER && + !it.key.params.flags.contains(ConfigFlag.HIDDEN) + ) { containers[it.key.name] = PropertyPair(it.key, it.value) queryContainerRecursive(it.value.get() as ConfigContainer) } @@ -191,7 +218,7 @@ class FeaturesRootSection : Routes.Route() { private fun loadSearchHistory(): List { return context.sharedPreferences - .getString("features_search_history", "") + .getString("features_search_history", "") // stored as | separated ?.split("|") ?.map { it.trim() } ?.filter { it.isNotEmpty() } @@ -367,14 +394,10 @@ class FeaturesRootSection : Routes.Route() { @Composable private fun PropertyAction(property: PropertyPair<*>, registerClickCallback: RegisterClickCallback) { - val hapticFeedback = LocalHapticFeedback.current var showDialog by remember { mutableStateOf(false) } var dialogComposable by remember { mutableStateOf<@Composable () -> Unit>({}) } - fun registerDialogOnClickCallback() = registerClickCallback { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - showDialog = true - } + fun registerDialogOnClickCallback() = registerClickCallback { showDialog = true } if (showDialog) { Dialog( @@ -444,14 +467,14 @@ class FeaturesRootSection : Routes.Route() { verticalArrangement = Arrangement.spacedBy(6.dp) ) { Text( - text = context.translation["manager.dialogs.file_imports.settings_select_file_hint"] ?: "Select file", + text = context.translation["manager.dialogs.file_imports.settings_select_file_hint"], fontSize = 18.sp, fontWeight = FontWeight.ExtraBold, color = Color.White ) if (isEmpty) { Text( - text = context.translation["manager.dialogs.file_imports.no_files_settings_hint"] ?: "No files found", + text = context.translation["manager.dialogs.file_imports.no_files_settings_hint"], fontSize = 15.sp, fontWeight = FontWeight.Medium, color = PurrfectPalette.textSecondary, @@ -461,7 +484,7 @@ class FeaturesRootSection : Routes.Route() { } else { Text( text = translation["manager.dialogs.file_imports.settings_select_file_subtitle"] - ?: translation["manager.dialogs.file_imports.settings_select_file_hint"] ?: "Select a file to use", + ?: translation["manager.dialogs.file_imports.settings_select_file_hint"], fontSize = 13.sp, color = PurrfectPalette.textSecondary, textAlign = TextAlign.Center @@ -476,7 +499,6 @@ class FeaturesRootSection : Routes.Route() { .fillMaxWidth() .padding(vertical = 4.dp) .clickable { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) selectedFile = if (isSelected) null else file.name propertyValue.setAny(selectedFile) persistConfig() @@ -545,7 +567,6 @@ class FeaturesRootSection : Routes.Route() { if (property.key.params.flags.contains(ConfigFlag.FOLDER)) { IconButton(onClick = registerClickCallback { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) activityLauncher { chooseFolder { uri -> propertyValue.setAny(uri) @@ -561,10 +582,13 @@ class FeaturesRootSection : Routes.Route() { when (val dataType = remember { property.key.dataType.type }) { DataProcessors.Type.BOOLEAN -> { var state by remember { mutableStateOf(propertyValue.get() as Boolean) } + val hapticFeedback = LocalHapticFeedback.current Switch( checked = state, onCheckedChange = registerClickCallback { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + if (context.config.root.global.uiSettings.hapticFeedback.get()) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + } state = state.not() propertyValue.setAny(state) persistConfig() @@ -615,7 +639,7 @@ class FeaturesRootSection : Routes.Route() { alertDialogs.MultipleSelectionDialog(property) } DataProcessors.Type.STRING, DataProcessors.Type.INTEGER, DataProcessors.Type.FLOAT -> { - // Verify if property handles message lists + // Check if this is a message list property val isMessageListProperty = property.key.name.endsWith("_messages") if (isMessageListProperty) { alertDialogs.MessageListPropertyDialog(property) { showDialog = false } @@ -635,7 +659,7 @@ class FeaturesRootSection : Routes.Route() { onClick = it ) } else { - // Verify if property handles message lists + // Check if this is a message list property val isMessageListProperty = property.key.name.endsWith("_messages") if (isMessageListProperty) { // Show message count @@ -651,11 +675,7 @@ class FeaturesRootSection : Routes.Route() { color = Color.White.copy(alpha = 0.06f), tonalElevation = 0.dp, shadowElevation = 0.dp, - border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)), - modifier = Modifier.clickable { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - it() - } + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)) ) { Text( text = translation.format("search_results_count", "count" to messageCount.toString()), @@ -665,10 +685,7 @@ class FeaturesRootSection : Routes.Route() { ) } } else { - IconButton(onClick = { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - it() - }) { + IconButton(onClick = it) { Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null) } } @@ -692,7 +709,6 @@ class FeaturesRootSection : Routes.Route() { val container = propertyValue.get() as ConfigContainer registerClickCallback { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) routes.navController.navigate(FEATURE_CONTAINER_ROUTE.replace("{name}", property.name)) } @@ -714,10 +730,13 @@ class FeaturesRootSection : Routes.Route() { )) } + val hapticFeedback = LocalHapticFeedback.current Switch( checked = state, onCheckedChange = { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + if (context.config.root.global.uiSettings.hapticFeedback.get()) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + } state = state.not() container.globalState = state persistConfig() @@ -734,15 +753,11 @@ class FeaturesRootSection : Routes.Route() { text: String, onClick: () -> Unit ) { - val haptic = LocalHapticFeedback.current Surface( modifier = Modifier .size(52.dp) .clip(CircleShape) - .clickable { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - onClick() - }, + .clickable { onClick() }, shape = CircleShape, color = Color.White.copy(alpha = 0.08f), tonalElevation = 0.dp, @@ -796,6 +811,7 @@ class FeaturesRootSection : Routes.Route() { val cardShape = RoundedCornerShape(22.dp) val interactionSource = remember { MutableInteractionSource() } + val density = LocalDensity.current val cardBorder = remember { Brush.linearGradient( listOf( @@ -870,14 +886,14 @@ class FeaturesRootSection : Routes.Route() { verticalArrangement = Arrangement.spacedBy(6.dp) ) { Text( - text = context.translation[property.key.propertyName()] ?: property.key.name, + text = context.translation[property.key.propertyName()], fontSize = 17.sp, fontWeight = FontWeight.ExtraBold, color = PurrfectPalette.textPrimary, lineHeight = 20.sp ) Text( - text = context.translation[property.key.propertyDescription()] ?: "", + text = context.translation[property.key.propertyDescription()], fontSize = 13.sp, lineHeight = 16.sp, color = PurrfectPalette.textSecondary @@ -887,7 +903,7 @@ class FeaturesRootSection : Routes.Route() { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { property.key.params.notices.forEach { NoticeBadge( - text = context.translation["features.notices.${it.key}"] ?: it.key, + text = context.translation["features.notices.${it.key}"], color = noticeColorMap[it.key] ?: Color(0xFFFFFB87) ) } @@ -927,17 +943,241 @@ class FeaturesRootSection : Routes.Route() { } } + @Composable + private fun FeatureSearchBar(rowScope: RowScope, focusRequester: FocusRequester) { + var searchValue by remember { mutableStateOf("") } + val isOverlay = remember { context.sharedPreferences.getBoolean("overlay_active", false) } + val scope = rememberCoroutineScope() + var currentSearchJob by remember { mutableStateOf(null) } + val searchHistory = remember { mutableStateListOf().apply { addAll(loadSearchHistory()) } } + fun launchSearch(term: String, record: Boolean, delayMs: Long = 0L) { + val keyword = term.trim() + if (keyword.isEmpty()) { + navigateToMainRoot() + return + } + currentSearchJob?.cancel() + currentSearchJob = scope.launch { + if (delayMs > 0) delay(delayMs) + if (record) upsertHistory(keyword, searchHistory) + routes.navController.navigate( + SEARCH_FEATURE_ROUTE.replace("{keyword}", keyword), + NavOptions.Builder() + .setLaunchSingleTop(true) + .setPopUpTo(routeInfo.id, false) + .build() + ) + } + } + + rowScope.apply { + val searchBorder = remember { + Brush.linearGradient( + listOf( + Color(0xFF8C7BFF).copy(alpha = 0.48f), + Color(0xFF5FD8FF).copy(alpha = 0.4f) + ) + ) + } + Surface( + modifier = Modifier + .focusRequester(focusRequester) + .weight(1f, fill = true) + .padding(end = 10.dp) + .height(62.dp), + shape = RoundedCornerShape(18.dp), + tonalElevation = 8.dp, + shadowElevation = 0.dp, + border = BorderStroke(1.dp, searchBorder), + color = Color.Transparent + ) { + TextField( + value = searchValue, + onValueChange = { keyword -> + searchValue = keyword + if (keyword.isEmpty()) { + if (isOverlay) { + routes.navController.popBackStack(routeInfo.id, false) + } + } else { + launchSearch(keyword, record = false, delayMs = 250L) + } + }, + keyboardActions = KeyboardActions(onDone = { + launchSearch(searchValue, record = true, delayMs = 0L) + focusRequester.freeFocus() + }), + singleLine = true, + leadingIcon = { + Surface( + shape = CircleShape, + color = Color.White.copy(alpha = 0.16f), + tonalElevation = 0.dp + ) { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = null, + tint = Color.White, + modifier = Modifier.padding(8.dp) + ) + } + }, + placeholder = { + Text( + text = translation["search_button"], + color = Color(0xFFE0DCFF) + ) + }, + trailingIcon = { + if (searchValue.isNotEmpty()) { + IconButton(onClick = { + searchValue = "" + if (isOverlay) { + routes.navController.popBackStack(routeInfo.id, false) + } + focusRequester.requestFocus() + }) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = null, + tint = Color.White + ) + } + } + }, + colors = TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + focusedContainerColor = Color.White.copy(alpha = 0.08f), + unfocusedContainerColor = Color.White.copy(alpha = 0.06f), + cursorColor = Color.White, + focusedTextColor = Color.White, + unfocusedTextColor = Color.White, + disabledTextColor = Color.White.copy(alpha = 0.65f), + focusedPlaceholderColor = Color(0xFFE0DCFF), + unfocusedPlaceholderColor = Color(0xFFE0DCFF), + focusedLeadingIconColor = Color.White, + unfocusedLeadingIconColor = Color.White.copy(alpha = 0.9f), + focusedTrailingIconColor = Color.White, + unfocusedTrailingIconColor = Color.White.copy(alpha = 0.9f) + ) + ) + } + } + } + + @Composable + private fun SensitiveDataDialog( + onDismiss: () -> Unit, + onConfirm: (exportSensitiveData: Boolean, includeSavedLocations: Boolean) -> Unit + ) { + Dialog(onDismissRequest = onDismiss) { + val includeSavedLocations = remember { mutableStateOf(false) } + + Surface( + shape = RoundedCornerShape(24.dp), + color = Color.White.copy(alpha = 0.06f), + tonalElevation = 0.dp, + shadowElevation = 16.dp, + border = BorderStroke( + 1.dp, + Brush.linearGradient( + listOf( + PurrfectPalette.glowPrimary.copy(alpha = 0.55f), + PurrfectPalette.glowSecondary.copy(alpha = 0.45f) + ) + ) + ) + ) { + Box(modifier = Modifier.background(PurrfectPalette.cardOverlay)) { + Column( + modifier = Modifier + .padding(horizontal = 20.dp, vertical = 18.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + Text( + text = context.translation["manager.dialogs.export_config.title"], + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.ExtraBold + ), + color = Color.White, + textAlign = TextAlign.Center + ) + Text( + text = context.translation["manager.dialogs.export_config.content"], + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = PurrfectPalette.textSecondary, + modifier = Modifier.padding(horizontal = 6.dp) + ) + + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = context.translation["include_saved_locations"], + style = MaterialTheme.typography.bodyMedium, + color = Color.White + ) + val hapticFeedback = LocalHapticFeedback.current + Switch( + checked = includeSavedLocations.value, + onCheckedChange = { + if (context.config.root.global.uiSettings.hapticFeedback.get()) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + } + includeSavedLocations.value = it + }, + colors = purrfectSwitchColors() + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End) + ) { + Button( + onClick = { onConfirm(false, includeSavedLocations.value) }, + colors = ButtonDefaults.buttonColors( + containerColor = Color.White.copy(alpha = 0.08f), + contentColor = Color.White + ) + ) { + Text(context.translation["button.negative"]) + } + Button( + onClick = { onConfirm(true, includeSavedLocations.value) }, + colors = ButtonDefaults.buttonColors( + containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.32f), + contentColor = Color.White + ) + ) { + Text(context.translation["button.positive"]) + } + } + } + } + } + } + } + + override val topBarActions: @Composable (RowScope.() -> Unit) = {} + @OptIn(ExperimentalLayoutApi::class) @Composable private fun FloatingControls( isSearchResults: Boolean, + modifier: Modifier = Modifier, activeSectionTitle: String? = null, activeSectionSubtitle: String? = null, searchKeyword: String? = null, searchHistory: SnapshotStateList, onSearchQueryChange: (String) -> Unit, - onBack: (() -> Unit)? = null, - modifier: Modifier = Modifier + onBack: (() -> Unit)? = null ) { var showSearchBar by rememberSaveable { mutableStateOf(isSearchResults) } val focusRequester = remember { FocusRequester() } @@ -973,7 +1213,6 @@ class FeaturesRootSection : Routes.Route() { var showExportDialog by remember { mutableStateOf(false) } if (showResetConfirmationDialog) { - val haptic = LocalHapticFeedback.current Dialog(onDismissRequest = { showResetConfirmationDialog = false }) { Surface( shape = RoundedCornerShape(24.dp), @@ -996,12 +1235,12 @@ class FeaturesRootSection : Routes.Route() { verticalArrangement = Arrangement.spacedBy(14.dp) ) { Text( - text = context.translation["manager.dialogs.reset_config.title"] ?: "Reset Config", + text = context.translation["manager.dialogs.reset_config.title"], style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.ExtraBold), color = Color.White ) Text( - text = context.translation["manager.dialogs.reset_config.content"] ?: "Reset all settings to default?", + text = context.translation["manager.dialogs.reset_config.content"], style = MaterialTheme.typography.bodyMedium, color = PurrfectPalette.textSecondary ) @@ -1010,10 +1249,7 @@ class FeaturesRootSection : Routes.Route() { horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End) ) { Button( - onClick = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - showResetConfirmationDialog = false - }, + onClick = { showResetConfirmationDialog = false }, colors = ButtonDefaults.buttonColors( containerColor = Color.White.copy(alpha = 0.08f), contentColor = Color.White @@ -1023,9 +1259,8 @@ class FeaturesRootSection : Routes.Route() { } Button( onClick = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) context.config.reset() - context.shortToast(context.translation["manager.dialogs.reset_config.success_toast"] ?: "Reset successful") + context.shortToast(context.translation["manager.dialogs.reset_config.success_toast"]) showResetConfirmationDialog = false }, colors = ButtonDefaults.buttonColors( @@ -1055,41 +1290,37 @@ class FeaturesRootSection : Routes.Route() { ) } - val haptic = LocalHapticFeedback.current val actions = remember { listOf( - Triple(translation["export_option"] ?: "Export", Icons.Filled.SaveAlt) { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - showExportDialog = true + Triple(translation["export_option"], Icons.Filled.SaveAlt) { showExportDialog = true }, + Triple(translation["import_option"], Icons.Filled.FileDownload) { + activityLauncher { + openFile("application/json") { uri -> + context.androidContext.contentResolver.openInputStream(Uri.parse(uri))?.use { + routes.configJsonForImport = it.readBytes().toString(Charsets.UTF_8) + routes.navController.navigate(Routes.CONFIG_IMPORT_CONFIRMATION_ROUTE) + } + } + } }, - Triple(translation["import_option"] ?: "Import", Icons.Filled.FileDownload) { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - activityLauncher { - openFile("application/json") { uriString -> - runCatching { - val uri = android.net.Uri.parse(uriString) - context.androidContext.contentResolver.openInputStream(uri)?.use { - routes.configJsonForImport = it.readBytes().toString(Charsets.UTF_8) - routes.navController.navigate(Routes.CONFIG_IMPORT_CONFIRMATION_ROUTE) - } - }.onFailure { err -> - context.log.error("Failed to read config file", err) - context.longToast(translation.format("config_import_failure_toast", "error" to (err.message ?: "Unknown"))) - } - } - } - }, - - Triple(translation["reset_option"] ?: "Reset", Icons.Filled.Refresh) { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - showResetConfirmationDialog = true - } + Triple(translation["reset_option"], Icons.Filled.Refresh) { showResetConfirmationDialog = true } ) } - val headerTitle = activeSectionTitle ?: translation["manager.routes.features"] ?: "Features" + val topBarShape = RoundedCornerShape(26.dp) + val topBarBackground = remember { PurrfectPalette.cardOverlay } + val topBarBorder = remember { + Brush.linearGradient( + listOf( + PurrfectPalette.glowPrimary.copy(alpha = 0.6f), + PurrfectPalette.glowSecondary.copy(alpha = 0.52f) + ) + ) + } + + val headerTitle = activeSectionTitle ?: translation["manager.routes.features"] val subtitleText = when { - isSearchResults -> translation["search_button"] ?: "Search" + isSearchResults -> translation["search_button"] !activeSectionSubtitle.isNullOrBlank() -> activeSectionSubtitle else -> translation["manager.sections.features.subtitle"] ?: "" } @@ -1107,175 +1338,223 @@ class FeaturesRootSection : Routes.Route() { } } - Column(modifier = modifier) { - me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar( - title = headerTitle, - subtitle = if (showSearchBar) null else subtitleText, - onBack = onBack, - scrollOffset = if (showSearchBar) 0 else (routes.navigation?.globalScrollOffset ?: 0), - actions = { - if (showSearchBar) { - TextField( - value = searchValue, - onValueChange = { keywordValue -> - searchValue = keywordValue - if (keywordValue.text.isEmpty()) { - updateSearch("", record = false) - } else { - updateSearch(keywordValue.text, record = false) + Box( + modifier = modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(horizontal = 14.dp, vertical = 0.dp) + .zIndex(1f) + ) { + Surface( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth(), + shape = topBarShape, + color = PurrfectPalette.cardOverlayColor, + border = BorderStroke(1.dp, topBarBorder), + tonalElevation = 0.dp, + shadowElevation = 8.dp + ) { + Box { + Box( + modifier = Modifier + .matchParentSize() + .clip(topBarShape) + .background(topBarBackground) + ) + Column( + modifier = Modifier + .padding(horizontal = 14.dp, vertical = 12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + if (onBack != null) { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = context.translation["common.back"], + tint = Color.White + ) } - }, - modifier = Modifier - .weight(1f) - .focusRequester(focusRequester), - singleLine = true, - placeholder = { Text(text = translation["search_button"] ?: "Search", color = Color(0xFFE0DCFF)) }, - leadingIcon = { - Icon( - imageVector = Icons.Filled.Search, - contentDescription = null, - tint = Color.White - ) - }, - trailingIcon = { - if (searchValue.text.isNotEmpty()) { - IconButton(onClick = { - searchValue = TextFieldValue("", TextRange(0)) - updateSearch("", record = false) - if (isSearchResults) { - if (isOverlay) { - routes.navController.popBackStack(routeInfo.id, false) - } + } + + if (showSearchBar) { + TextField( + value = searchValue, + onValueChange = { keywordValue -> + searchValue = keywordValue + if (keywordValue.text.isEmpty()) { + updateSearch("", record = false) } else { - showSearchBar = false + updateSearch(keywordValue.text, record = false) + } + }, + modifier = Modifier + .weight(1f) + .focusRequester(focusRequester), + singleLine = true, + placeholder = { Text(text = translation["search_button"], color = Color(0xFFE0DCFF)) }, + leadingIcon = { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = null, + tint = Color.White + ) + }, + trailingIcon = { + if (searchValue.text.isNotEmpty()) { + IconButton(onClick = { + searchValue = TextFieldValue("", TextRange(0)) + updateSearch("", record = false) + if (isSearchResults) { + if (isOverlay) { + routes.navController.popBackStack(routeInfo.id, false) + } + } else { + showSearchBar = false + } + }) { + Icon(Icons.Filled.Close, contentDescription = null, tint = Color.White) + } + } + }, + keyboardActions = KeyboardActions( + onDone = { + updateSearch(searchValue.text, record = true) + } + ), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + cursorColor = Color.White, + focusedTextColor = Color.White, + unfocusedTextColor = Color.White, + disabledTextColor = Color.White.copy(alpha = 0.65f), + focusedPlaceholderColor = Color(0xFFE0DCFF), + unfocusedPlaceholderColor = Color(0xFFE0DCFF), + focusedLeadingIconColor = Color.White, + unfocusedLeadingIconColor = Color.White.copy(alpha = 0.9f), + focusedTrailingIconColor = Color.White, + unfocusedTrailingIconColor = Color.White.copy(alpha = 0.9f) + ) + ) + LaunchedEffect(Unit) { focusRequester.requestFocus() } + } else { + Column(modifier = Modifier.weight(1f)) { + Text( + text = headerTitle ?: "", + color = Color.White, + fontWeight = FontWeight.ExtraBold, + fontSize = 18.sp + ) + Text( + text = subtitleText, + color = Color(0xFFCEC8FF), + fontSize = 12.sp + ) + } + } + + if (!showSearchBar || searchValue.text.isEmpty()) { + IconButton(onClick = { + if (showSearchBar) { + searchValue = TextFieldValue("", TextRange(0)) + if (isOverlay) { + routes.navController.popBackStack(routeInfo.id, false) + } + } + showSearchBar = !showSearchBar + }) { + Icon( + imageVector = if (showSearchBar) Icons.Filled.Close else Icons.Filled.Search, + contentDescription = null, + tint = Color.White + ) + } + } + + if (context.activity != null) { + Box { + IconButton(onClick = { showExportDropdownMenu = !showExportDropdownMenu }) { + Icon( + imageVector = Icons.Filled.MoreVert, + contentDescription = null, + tint = PurrfectPalette.glowSecondary + ) + } + DropdownMenu( + expanded = showExportDropdownMenu, + onDismissRequest = { showExportDropdownMenu = false }, + offset = DpOffset(0.dp, 8.dp), + containerColor = Color(0xFF161821), + shape = RoundedCornerShape(14.dp), + tonalElevation = 8.dp, + shadowElevation = 12.dp + ) { + actions.forEach { (name, icon, action) -> + DropdownMenuItem( + leadingIcon = { + Icon( + imageVector = icon, + contentDescription = null, + tint = PurrfectPalette.glowPrimary + ) + }, + text = { Text(text = name, color = Color.White) }, + onClick = { + action() + showExportDropdownMenu = false + }, + colors = MenuDefaults.itemColors( + textColor = Color.White, + leadingIconColor = PurrfectPalette.glowPrimary + ) + ) } - }) { - Icon(Icons.Filled.Close, contentDescription = null, tint = Color.White) } } - }, - keyboardActions = KeyboardActions( - onDone = { - updateSearch(searchValue.text, record = true) - } - ), - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - cursorColor = Color.White, - focusedTextColor = Color.White, - unfocusedTextColor = Color.White, - disabledTextColor = Color.White.copy(alpha = 0.65f), - focusedPlaceholderColor = Color(0xFFE0DCFF), - unfocusedPlaceholderColor = Color(0xFFE0DCFF), - focusedLeadingIconColor = Color.White, - unfocusedLeadingIconColor = Color.White.copy(alpha = 0.9f), - focusedTrailingIconColor = Color.White, - unfocusedTrailingIconColor = Color.White.copy(alpha = 0.9f) - ) - ) - LaunchedEffect(Unit) { focusRequester.requestFocus() } - } else { - IconButton(onClick = { - showSearchBar = true - }) { - Icon( - imageVector = Icons.Filled.Search, - contentDescription = null, - tint = Color.White - ) - } - } - - if (context.activity != null) { - Box { - IconButton(onClick = { showExportDropdownMenu = !showExportDropdownMenu }) { - Icon( - imageVector = Icons.Filled.MoreVert, - contentDescription = null, - tint = PurrfectPalette.glowSecondary - ) } - DropdownMenu( - expanded = showExportDropdownMenu, - onDismissRequest = { showExportDropdownMenu = false }, - offset = DpOffset(0.dp, 8.dp), - containerColor = Color(0xFF161821), - shape = RoundedCornerShape(14.dp), - tonalElevation = 8.dp, - shadowElevation = 12.dp - ) { - actions.forEach { (name, icon, action) -> - DropdownMenuItem( - leadingIcon = { - Icon( - imageVector = icon, - contentDescription = null, - tint = PurrfectPalette.glowPrimary - ) - }, - text = { Text(text = name ?: "", color = Color.White) }, + } + + if (showSearchBar && combinedSuggestions.isNotEmpty()) { + Spacer(Modifier.height(10.dp)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + combinedSuggestions.forEach { suggestion -> + AssistChip( onClick = { - action() - showExportDropdownMenu = false + searchValue = TextFieldValue(suggestion, TextRange(suggestion.length)) + updateSearch(suggestion, record = true) }, - colors = MenuDefaults.itemColors( - textColor = Color.White, - leadingIconColor = PurrfectPalette.glowPrimary + label = { Text(text = suggestion, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null, tint = Color.White) }, + colors = AssistChipDefaults.assistChipColors( + containerColor = Color.White.copy(alpha = 0.08f), + labelColor = Color.White, + leadingIconContentColor = Color.White ) ) } } - } - } - } - ) - - // Search suggestions overlay container - if (showSearchBar && combinedSuggestions.isNotEmpty()) { - Surface( - modifier = Modifier - .padding(top = 10.dp) - .fillMaxWidth(), - color = PurrfectPalette.cardOverlayColor, - shape = RoundedCornerShape(18.dp), - border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)) - ) { - Column(modifier = Modifier.padding(12.dp)) { - FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - combinedSuggestions.forEach { suggestion -> - AssistChip( - onClick = { - searchValue = TextFieldValue(suggestion, TextRange(suggestion.length)) - updateSearch(suggestion, record = true) - }, - label = { Text(text = suggestion, maxLines = 1, overflow = TextOverflow.Ellipsis) }, - leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null, tint = Color.White) }, - colors = AssistChipDefaults.assistChipColors( - containerColor = Color.White.copy(alpha = 0.08f), - labelColor = Color.White, - leadingIconContentColor = Color.White - ) - ) - } - } - if (searchHistory.isNotEmpty()) { - Spacer(Modifier.height(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - TextButton( - onClick = { - searchHistory.clear() - saveSearchHistory(emptyList()) - } + if (searchHistory.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End ) { - Icon(Icons.Filled.Delete, contentDescription = null, tint = Color.White.copy(alpha = 0.85f)) - Spacer(Modifier.width(6.dp)) - Text(text = translation["clear_history"] ?: "Clear history", color = Color.White) + TextButton( + onClick = { + searchHistory.clear() + saveSearchHistory(emptyList()) + } + ) { + Icon(Icons.Filled.Delete, contentDescription = null, tint = Color.White.copy(alpha = 0.85f)) + Spacer(Modifier.width(6.dp)) + Text(text = translation["clear_history"], color = Color.White) + } } } } @@ -1283,6 +1562,7 @@ class FeaturesRootSection : Routes.Route() { } } } + } @Composable @@ -1296,14 +1576,14 @@ class FeaturesRootSection : Routes.Route() { onBack: (() -> Unit)? = null ) { val density = LocalDensity.current - var controlsHeight by remember { mutableStateOf(100.dp) } + var controlsHeight by remember { mutableStateOf(96.dp) } val listState = rememberLazyListState() val sharedSearchHistory = remember { mutableStateListOf().apply { addAll(loadSearchHistory()) } } var liveSearchQuery by rememberSaveable { mutableStateOf(searchKeyword.orEmpty()) } val isActiveSearch = isSearchResults || liveSearchQuery.isNotBlank() val globalSearchProperties = remember(enableGlobalSearch) { if (enableGlobalSearch) { - allProperties.map { PropertyPair(it.key, it.value) } + allProperties.filter { isSearchVisibleProperty(it.key) }.map { PropertyPair(it.key, it.value) } } else { emptyList() } @@ -1330,22 +1610,17 @@ class FeaturesRootSection : Routes.Route() { } } - 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()) { FeatureAuroraBackdrop() - LazyColumn( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize(), state = listState, verticalArrangement = Arrangement.spacedBy(6.dp), contentPadding = PaddingValues( start = 6.dp, end = 6.dp, - top = controlsHeight, + top = controlsHeight + 12.dp, bottom = routes.bottomPadding ) ) { @@ -1363,7 +1638,6 @@ class FeaturesRootSection : Routes.Route() { } item { Spacer(modifier = Modifier.height(12.dp)) } } - FloatingControls( isSearchResults = isActiveSearch, activeSectionTitle = activeSectionTitle, @@ -1372,7 +1646,12 @@ class FeaturesRootSection : Routes.Route() { searchHistory = sharedSearchHistory, onSearchQueryChange = { liveSearchQuery = it }, onBack = onBack, - modifier = Modifier.headerHeightTracker { controlsHeight = it } + modifier = Modifier.onGloballyPositioned { + val newHeight = with(density) { it.size.height.toDp() } + if (newHeight != controlsHeight) { + controlsHeight = newHeight + } + } ) } } @@ -1398,110 +1677,7 @@ class FeaturesRootSection : Routes.Route() { } } - @Composable - private fun SensitiveDataDialog( - onDismiss: () -> Unit, - onConfirm: (exportSensitiveData: Boolean, includeSavedLocations: Boolean) -> Unit - ) { - Dialog(onDismissRequest = onDismiss) { - val includeSavedLocations = remember { mutableStateOf(false) } - Surface( - shape = RoundedCornerShape(24.dp), - color = Color.White.copy(alpha = 0.06f), - tonalElevation = 0.dp, - shadowElevation = 16.dp, - border = BorderStroke( - 1.dp, - Brush.linearGradient( - listOf( - PurrfectPalette.glowPrimary.copy(alpha = 0.55f), - PurrfectPalette.glowSecondary.copy(alpha = 0.45f) - ) - ) - ) - ) { - Box(modifier = Modifier.background(PurrfectPalette.cardOverlay)) { - Column( - modifier = Modifier - .padding(horizontal = 20.dp, vertical = 18.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(14.dp) - ) { - Text( - text = context.translation["manager.dialogs.export_config.title"] ?: "Export Config", - style = MaterialTheme.typography.titleLarge.copy( - fontWeight = FontWeight.ExtraBold - ), - color = Color.White, - textAlign = TextAlign.Center - ) - Text( - text = context.translation["manager.dialogs.export_config.content"] ?: "Include sensitive data?", - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - color = PurrfectPalette.textSecondary, - modifier = Modifier.padding(horizontal = 6.dp) - ) - - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = translation["include_saved_locations"] ?: "Include Saved Locations", - style = MaterialTheme.typography.bodyMedium, - color = Color.White - ) - val hapticFeedback = LocalHapticFeedback.current - Switch( - checked = includeSavedLocations.value, - onCheckedChange = { - if (context.config.root.global.uiSettings.hapticFeedback.get()) { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - } - includeSavedLocations.value = it - }, - colors = purrfectSwitchColors() - ) - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End) - ) { - val haptic = LocalHapticFeedback.current - Button( - onClick = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - onConfirm(false, includeSavedLocations.value) - }, - colors = ButtonDefaults.buttonColors( - containerColor = Color.White.copy(alpha = 0.08f), - contentColor = Color.White - ) - ) { - Text(context.translation["button.negative"]) - } - Button( - onClick = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - onConfirm(true, includeSavedLocations.value) - }, - colors = ButtonDefaults.buttonColors( - containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.32f), - contentColor = Color.White - ) - ) { - Text(context.translation["button.positive"]) - } - } - } - } - } - } - } @Composable private fun Container( diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/ManageRuleFeature.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/ManageRuleFeature.kt index a1a8648b..8bac3ca2 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/ManageRuleFeature.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/ManageRuleFeature.kt @@ -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 } - ) } } } diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeAbout.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeAbout.kt index 2177844a..d1a7949a 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeAbout.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeAbout.kt @@ -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 = "", 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 ) - } +} } } } diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeLogs.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeLogs.kt index 48caab3b..d82d5ce4 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeLogs.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeLogs.kt @@ -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(null) } val visibleLogs = remember { mutableStateListOf() } + 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( diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeRootSection.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeRootSection.kt index a6c8b747..8a84379c 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeRootSection.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeRootSection.kt @@ -3,8 +3,12 @@ package me.eternal.purrfectsnap.ui.manager.pages.home import android.content.SharedPreferences import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.* +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +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.Canvas import androidx.compose.foundation.ExperimentalFoundationApi @@ -13,44 +17,95 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.PaddingValues +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.heightIn +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.outlined.Widgets -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.State +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +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 import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size 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.graphics.vector.ImageVector -import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily 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.withLink +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.unit.lerp import androidx.compose.ui.zIndex import androidx.navigation.NavBackStackEntry import kotlinx.coroutines.Dispatchers @@ -73,18 +128,16 @@ import me.eternal.purrfectsnap.ui.manager.data.Updater import me.eternal.purrfectsnap.ui.manager.data.Updater.Channel import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper -import me.eternal.purrfectsnap.ui.util.PurrfectMarqueeText +import me.eternal.purrfectsnap.ui.util.AlertDialogs import me.eternal.purrfectsnap.ui.util.scaleOnPress -import me.eternal.purrfectsnap.ui.util.Motion -import me.eternal.purrfectsnap.ui.util.headerHeightTracker import okhttp3.OkHttpClient import okhttp3.Request -import androidx.compose.foundation.ScrollState class HomeRootSection : Routes.Route() { override val translation by lazy { context.translation.getCategory("manager.sections.home") } companion object { + private const val QUICK_TILES_INITIALIZED_PREF = "quick_tiles_initialized" val cardMargin = 10.dp val pageBackgroundGradient = Brush.verticalGradient( listOf( @@ -95,7 +148,10 @@ class HomeRootSection : Routes.Route() { ) } - private val announcementsUrl = "https://raw.githubusercontent.com/particle-box/PurrfectSnap/dev/announcements.txt" + private val changelogClient by lazy { OkHttpClient() } + private val changelogStableUrl = "https://raw.githubusercontent.com/particle-box/PurrfectSnap/dev/changelogs-stable.txt" + private val changelogPrereleaseUrl = "https://raw.githubusercontent.com/particle-box/PurrfectSnap/dev/changelogs-prerelease.txt" + private val announcementsUrl = "https://raw.githubusercontent.com/particle-box/PurrfectSnap/dev/announcements.txt" private val heroGradientColors = listOf( Color(0xFF5C4B99), @@ -107,18 +163,30 @@ class HomeRootSection : Routes.Route() { Color(0xFF151127) ) private lateinit var activityLauncherHelper: ActivityLauncherHelper - @Composable - private fun rememberCards(): Map, (Routes) -> Unit> { - return remember { - val map = EnumQuickActions.entries.associate { - (context.translation["actions.${it.key}.name"] to it.icon) to it.action - }.toMutableMap() + data class QaCard(val id: String, val name: String, val icon: ImageVector, val action: (Routes) -> Unit) + private val cardEntries by lazy { + val list = mutableListOf() + EnumQuickActions.entries.forEach { q -> + val name = context.translation["actions.${q.key}.name"] + list.add(QaCard(id = "quick.${q.key}", name = name, icon = q.icon, action = q.action)) + } + EnumAction.entries.forEach { a -> + val name = context.translation["actions.${a.key}.name"] + list.add(QaCard(id = "action.${a.key}", name = name, icon = a.icon, action = { context.launchActionIntent(a) })) + } + list + } + private val cards by lazy { + EnumQuickActions.entries.map { + (context.translation["actions.${it.key}.name"] to it.icon) to it.action + }.associate { + it.first to it.second + }.toMutableMap().apply { EnumAction.entries.forEach { action -> - map[context.translation["actions.${action.key}.name"] to action.icon] = { + this[context.translation["actions.${action.key}.name"] to action.icon] = { context.launchActionIntent(action) } } - map } } @@ -146,17 +214,13 @@ class HomeRootSection : Routes.Route() { onClick: (() -> Unit)? = null, tint: Color = MaterialTheme.colorScheme.onSurfaceVariant, containerColor: Color = MaterialTheme.colorScheme.primary.copy(alpha = 0.08f), - haptic: androidx.compose.ui.hapticfeedback.HapticFeedback ) { val interactionSource = remember { MutableInteractionSource() } val clickModifier = if (onClick != null) { Modifier.clickable( interactionSource = interactionSource, indication = LocalIndication.current - ) { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - onClick() - } + ) { onClick() } } else { Modifier } @@ -198,67 +262,30 @@ class HomeRootSection : Routes.Route() { icon: ImageVector, label: String? = null, contentDescription: String? = label, - shrinkFactor: Float = 1f, - haptic: androidx.compose.ui.hapticfeedback.HapticFeedback, onClick: () -> Unit, ) { Surface( - modifier = Modifier - .height(36.dp) - .widthIn(min = 36.dp), // Harmonized minimum footprint shape = RoundedCornerShape(40), color = Color.White.copy(alpha = 0.06f), - border = BorderStroke( - 1.dp, - Brush.linearGradient( - listOf( - PurrfectPalette.glowPrimary.copy(alpha = 0.55f), - PurrfectPalette.glowSecondary.copy(alpha = 0.35f) - ) - ) - ) + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)) ) { Row( modifier = Modifier .clip(RoundedCornerShape(40)) - .clickable { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - onClick() - } - .padding( - vertical = 6.dp, // Fixed height to prevent enlarging - horizontal = lerp(10.dp, 12.dp, shrinkFactor) - ), + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Icon( - imageVector = icon, - contentDescription = contentDescription, - tint = Color.White, - modifier = Modifier.size(20.dp).graphicsLayer { - val iconScale = 0.82f + (0.18f * shrinkFactor) - scaleX = iconScale - scaleY = iconScale - } - ) - // Fluid Label Morph: Continuous alpha and width to prevent jumping - if (label != null) { - val labelAlpha = (shrinkFactor - 0.1f).coerceIn(0f, 1f) - Spacer(modifier = Modifier.width((8 * shrinkFactor).dp)) + Icon(icon, contentDescription = contentDescription, tint = Color.White) + label?.let { Text( - text = label, - color = Color.White.copy(alpha = labelAlpha), - fontSize = 12.sp, + text = it, + color = Color.White, + fontSize = 13.sp, fontWeight = FontWeight.Medium, maxLines = 1, - overflow = TextOverflow.Clip, - modifier = Modifier - .graphicsLayer { - alpha = labelAlpha - translationX = (-4 * (1f - shrinkFactor)).dp.toPx() - } - .widthIn(max = (75 * shrinkFactor).dp) + overflow = TextOverflow.Ellipsis ) } } @@ -266,109 +293,108 @@ class HomeRootSection : Routes.Route() { } @Composable - private fun RowScope.HomeActionChips(scrollState: ScrollState, haptic: androidx.compose.ui.hapticfeedback.HapticFeedback) { - // Optimize: Use derivedStateOf to prevent constant re-composition during scroll - val shrinkFactor by remember(scrollState.value) { - derivedStateOf { (1f - (scrollState.value.toFloat() / Motion.HEADER_MORPH_THRESHOLD)).coerceIn(0f, 1f) } + private fun InfoCard(content: @Composable ColumnScope.() -> Unit) { + OutlinedCard( + modifier = Modifier + .padding(start = cardMargin, end = cardMargin) + .fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(all = 10.dp), + content = content + ) } - - TopBarActionChip( - icon = Icons.Filled.BugReport, - label = context.translation["manager.routes.home_logs"], - shrinkFactor = shrinkFactor, - haptic = haptic - ) { routes.homeLogs.navigate() } - TopBarActionChip( - icon = Icons.Filled.Settings, - label = context.translation["manager.routes.home_settings"], - shrinkFactor = shrinkFactor, - haptic = haptic - ) { routes.settings.navigate() } } @Composable - private fun LivingPurrAura(isActive: Boolean, haptic: androidx.compose.ui.hapticfeedback.HapticFeedback) { - val infiniteTransition = rememberInfiniteTransition(label = "aura") - - val pulseScale by infiniteTransition.animateFloat( - initialValue = 0.88f, - targetValue = 1.12f, + private fun RowScope.HomeActionChips() { + TopBarActionChip( + icon = Icons.Filled.BugReport, + label = context.translation["manager.routes.home_logs"] + ) { routes.homeLogs.navigate() } + TopBarActionChip( + icon = Icons.Filled.Info, + label = translation["manager.routes.home_about"] + ) { routes.about.navigate() } + } + + + @Composable + private fun AuroraBackground() { + val infiniteTransition = rememberInfiniteTransition(label = "aurora") + val driftX by infiniteTransition.animateFloat( + initialValue = -120f, + targetValue = 220f, animationSpec = infiniteRepeatable( - animation = tween(1600, easing = EaseInOutSine), + animation = tween(durationMillis = 16000, easing = LinearEasing), repeatMode = RepeatMode.Reverse ), - label = "pulse" + label = "driftX" ) - - val glow1 by infiniteTransition.animateFloat( - initialValue = 0f, targetValue = 1f, - animationSpec = infiniteRepeatable(tween(3200, easing = LinearEasing), RepeatMode.Restart), - label = "g1" - ) - val glow2 by infiniteTransition.animateFloat( - initialValue = 0f, targetValue = 1f, + val driftY by infiniteTransition.animateFloat( + initialValue = 80f, + targetValue = -140f, animationSpec = infiniteRepeatable( - animation = tween(3200, delayMillis = 1100, easing = LinearEasing), - repeatMode = RepeatMode.Restart + animation = tween(durationMillis = 14000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse ), - label = "g2" + label = "driftY" ) - val glow3 by infiniteTransition.animateFloat( - initialValue = 0f, targetValue = 1f, + val shimmer by infiniteTransition.animateFloat( + initialValue = -120f, + targetValue = 160f, animationSpec = infiniteRepeatable( - animation = tween(3200, delayMillis = 2200, easing = LinearEasing), - repeatMode = RepeatMode.Restart + animation = tween(durationMillis = 11000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse ), - label = "g3" + label = "shimmer" ) - val coreColor by animateColorAsState( - targetValue = if (isActive) PurrfectPalette.glowPrimary else Color(0xFF8C8CA3), - animationSpec = tween(800), label = "coreColor" - ) - val secondaryColor by animateColorAsState( - targetValue = if (isActive) PurrfectPalette.glowSecondary else Color(0xFF6B6B7A), - animationSpec = tween(800), label = "coreColor" + val primaryGlow = MaterialTheme.colorScheme.primary.copy(alpha = 0.28f) + val tertiaryGlow = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.22f) + val trailGradient = listOf( + MaterialTheme.colorScheme.primary.copy(alpha = 0.18f), + MaterialTheme.colorScheme.secondary.copy(alpha = 0.16f), + MaterialTheme.colorScheme.tertiary.copy(alpha = 0.14f) ) - Canvas(modifier = Modifier.size(44.dp)) { - val center = Offset(size.width / 2, size.height / 2) - val baseRadius = 6.dp.toPx() - - fun drawAuroraGlow(progress: Float, alphaMultiplier: Float) { - if (!isActive || progress <= 0f) return - val auroraRadius = baseRadius * (1.2f + 4.5f * progress) - drawCircle( - brush = Brush.radialGradient( - 0.0f to coreColor.copy(alpha = 0.25f * (1f - progress) * alphaMultiplier), - 0.6f to secondaryColor.copy(alpha = 0.12f * (1f - progress) * alphaMultiplier), - 1.0f to Color.Transparent, - center = center, - radius = auroraRadius - ), - radius = auroraRadius, - center = center - ) - } - - drawAuroraGlow(glow1, 0.8f) - drawAuroraGlow(glow2, 0.5f) - drawAuroraGlow(glow3, 0.3f) - + Canvas(modifier = Modifier.fillMaxSize()) { drawCircle( brush = Brush.radialGradient( - colors = listOf(coreColor, secondaryColor), - center = center, - radius = baseRadius * pulseScale + colors = listOf(primaryGlow, Color.Transparent), + center = Offset( + x = size.width * 0.25f + driftX, + y = size.height * 0.18f + driftY * 0.4f + ), + radius = size.minDimension * 0.9f ), - radius = baseRadius * pulseScale, - center = center + alpha = 0.85f ) - drawCircle( - color = Color.White.copy(alpha = 0.5f), - radius = (baseRadius * pulseScale) * 0.25f, - center = Offset(center.x - (baseRadius * pulseScale) * 0.3f, center.y - (baseRadius * pulseScale) * 0.3f) + brush = Brush.radialGradient( + colors = listOf(tertiaryGlow, Color.Transparent), + center = Offset( + x = size.width * 0.78f - driftX * 0.45f, + y = size.height * 0.72f + ), + radius = size.minDimension * 0.95f + ), + alpha = 0.9f + ) + drawRect( + brush = Brush.linearGradient( + colors = trailGradient, + start = Offset(x = 0f, y = size.height * 0.15f + shimmer), + end = Offset(x = size.width, y = size.height * 0.9f + shimmer) + ), + size = this.size, + alpha = 0.24f ) } } @@ -383,10 +409,12 @@ class HomeRootSection : Routes.Route() { onUpdateAction: () -> Unit, channelLabel: String, isPurrAuraActive: Boolean, - onAboutClick: () -> Unit, - avenirNext: FontFamily, - scrollOffset: () -> Int, - haptic: androidx.compose.ui.hapticfeedback.HapticFeedback + onWebsiteClick: () -> Unit, + onTelegramClick: () -> Unit, + onGithubClick: () -> Unit, + authorName: String, + onManageClick: () -> Unit, + avenirNext: FontFamily ) { val heroShape = RoundedCornerShape(36.dp) val gitHashShort = remember { (context.installationSummary.modInfo?.gitHash ?: BuildConfig.GIT_HASH).take(7) } @@ -394,7 +422,11 @@ class HomeRootSection : Routes.Route() { modifier = Modifier .padding(horizontal = cardMargin, vertical = 6.dp) .clip(heroShape) - .background(Brush.linearGradient(heroGradientColors)) + .background( + Brush.linearGradient( + heroGradientColors + ) + ) .border(1.dp, Color.White.copy(alpha = 0.1f), heroShape) ) { Column( @@ -409,94 +441,136 @@ class HomeRootSection : Routes.Route() { horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = "PurrfectSnap", - color = Color.White, - fontSize = 34.sp, - fontWeight = FontWeight.ExtraBold, - fontFamily = avenirNext, - modifier = Modifier.graphicsLayer { - // Starts at 250px scroll - alpha = (1f - ((scrollOffset() - 250f) / 120f)).coerceIn(0f, 1f) - translationY = (-scrollOffset() * 0.06f) - } + text = "PurrfectSnap", + color = Color.White, + fontSize = 34.sp, + fontWeight = FontWeight.ExtraBold, + fontFamily = avenirNext ) Text( - text = "By ΞTΞRNAL", - color = Color.White.copy(alpha = 0.75f), - fontSize = 14.sp, - fontFamily = avenirNext, - modifier = Modifier.graphicsLayer { - // Starts at 300px scroll - alpha = (1f - ((scrollOffset() - 300f) / 120f)).coerceIn(0f, 1f) - translationY = (-scrollOffset() * 0.04f) - } + text = "By ΞTΞRNAL", + color = Color.White.copy(alpha = 0.75f), + fontSize = 14.sp, + fontFamily = avenirNext ) Text( - text = translation["hero_tagline"], - color = Color.White.copy(alpha = 0.9f), - fontSize = 15.sp, - lineHeight = 20.sp, - textAlign = TextAlign.Center, - modifier = Modifier.graphicsLayer { - // Starts at 350px scroll - alpha = (1f - ((scrollOffset() - 350f) / 120f)).coerceIn(0f, 1f) - translationY = (-scrollOffset() * 0.02f) - } + text = translation["hero_tagline"], + color = Color.White.copy(alpha = 0.9f), + fontSize = 15.sp, + lineHeight = 20.sp, + textAlign = TextAlign.Center ) } - FlowRow( - modifier = Modifier - .fillMaxWidth() - .graphicsLayer { - // Starts at 400px scroll - alpha = (1f - ((scrollOffset() - 400f) / 120f)).coerceIn(0f, 1f) - }, - horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - HeroBadge(translation.format("hero_version_label", "version" to versionName, "channel" to channelLabel)) - gitHashShort.takeIf { it.isNotBlank() && it.lowercase() != "unknown" }?.let { - HeroBadge(translation.format("hero_build_label", "build" to it)) - } - } + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + HeroBadge(translation.format("hero_version_label", "version" to versionName, "channel" to channelLabel)) + gitHashShort.takeIf { it.isNotBlank() && it.lowercase() != "unknown" }?.let { + HeroBadge(translation.format("hero_build_label", "build" to it)) + } + } if (latestUpdate != null) { Surface( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(20.dp), color = Color.White.copy(alpha = 0.08f), - border = BorderStroke(1.dp, Color.White.copy(alpha = 0.14f)) + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.14f)), + tonalElevation = 0.dp, + shadowElevation = 0.dp ) { Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp) ) { - Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text(translation["update_title"], color = Color.White, fontWeight = FontWeight.SemiBold, fontSize = 14.sp) - Text(translation.format("update_content", "version" to (latestUpdate.versionName)), color = Color.White.copy(alpha = 0.82f), fontSize = 12.sp) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = translation["update_title"], + color = Color.White, + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = translation.format( + "update_content", + "version" to (latestUpdate.versionName) + ), + color = Color.White.copy(alpha = 0.82f), + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) } - AnimatedContent(targetState = downloadState, label = "UpdateDownloadHero") { state -> + AnimatedContent( + targetState = downloadState, + label = "UpdateDownloadHero" + ) { state -> when (state) { UpdateDownloader.DownloadState.IDLE, UpdateDownloader.DownloadState.FAILED -> { - Button(onClick = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - onUpdateAction() - }, shape = RoundedCornerShape(50), colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color(0xFF1B152E))) { - Icon(Icons.Default.Download, contentDescription = null, modifier = Modifier.size(18.dp)) + Button( + onClick = onUpdateAction, + shape = RoundedCornerShape(50), + colors = ButtonDefaults.buttonColors( + containerColor = Color.White, + contentColor = Color(0xFF1B152E) + ), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)), + contentPadding = PaddingValues(12.dp) + ) { + Icon( + imageVector = Icons.Default.Download, + contentDescription = translation["download_icon_description"], + modifier = Modifier.size(18.dp) + ) } } + UpdateDownloader.DownloadState.DOWNLOADING -> { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { - CircularProgressIndicator(progress = { downloadProgress }, modifier = Modifier.size(28.dp), color = Color.White) - Text("${(downloadProgress * 100).toInt()}%", color = Color.White, fontWeight = FontWeight.SemiBold) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.padding(end = 6.dp) + ) { + CircularProgressIndicator( + progress = { downloadProgress }, + modifier = Modifier.size(28.dp), + strokeWidth = 3.dp, + color = Color.White + ) + Text( + text = "${(downloadProgress * 100).toInt()}%", + color = Color.White, + fontWeight = FontWeight.SemiBold + ) } } + UpdateDownloader.DownloadState.COMPLETED -> { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon(Icons.Default.Check, contentDescription = null, tint = Color(0xFFA3F0C2)) - Text(translation["update_ready_label"], color = Color.White, fontWeight = FontWeight.SemiBold) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = translation["completed_icon_description"], + tint = Color(0xFFA3F0C2) + ) + Text( + text = translation["update_ready_label"], + color = Color.White, + fontWeight = FontWeight.SemiBold + ) } } } @@ -506,14 +580,12 @@ class HomeRootSection : Routes.Route() { } Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(24.dp), color = Color.White.copy(alpha = 0.08f), + shape = RoundedCornerShape(24.dp), border = BorderStroke(1.dp, Color.White.copy(alpha = 0.10f)), tonalElevation = 0.dp, shadowElevation = 0.dp ) { - val unifiedButtonWidth = 180.dp Column( modifier = Modifier .fillMaxWidth() @@ -523,49 +595,40 @@ class HomeRootSection : Routes.Route() { ) { Surface( shape = RoundedCornerShape(50), - color = Color.White.copy(alpha = 0.12f), - border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f)), - modifier = Modifier.width(unifiedButtonWidth).height(46.dp), + color = Color.White.copy(alpha = 0.06f), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.10f)), tonalElevation = 0.dp, shadowElevation = 0.dp ) { Row( - modifier = Modifier.fillMaxSize().padding(horizontal = 14.dp), - horizontalArrangement = Arrangement.Center, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically ) { - Box(modifier = Modifier.size(20.dp), contentAlignment = Alignment.Center) { - LivingPurrAura(isActive = isPurrAuraActive, haptic = haptic) - } - Spacer(modifier = Modifier.width(8.dp)) + Box( + modifier = Modifier + .size(14.dp) + .clip(RoundedCornerShape(50)) + .background(if (isPurrAuraActive) PurrfectPalette.glowPrimary else Color(0xFF8C8CA3)) + ) Text( text = if (isPurrAuraActive) translation["purr_aura_active_label"] else translation["purr_aura_inactive_label"], color = Color.White, fontWeight = FontWeight.Bold, - fontSize = 13.sp + fontSize = 14.sp ) } } OutlinedButton( - onClick = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - onAboutClick() - }, - border = BorderStroke(1.dp, Color.White.copy(alpha = 0.35f)), - colors = ButtonDefaults.outlinedButtonColors( - containerColor = Color.White.copy(alpha = 0.06f), - contentColor = Color.White - ), - modifier = Modifier.width(unifiedButtonWidth).height(46.dp) + onClick = onManageClick, + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)), + colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White), + modifier = Modifier.align(Alignment.CenterHorizontally) ) { - Icon(Icons.Filled.Info, contentDescription = null, tint = Color.White, modifier = Modifier.size(18.dp)) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = translation.getOrNull("about_meet_team_button") ?: "About Us", - fontWeight = FontWeight.Bold, - fontSize = 13.sp - ) + Icon(Icons.Filled.Settings, contentDescription = null, tint = Color.White) + Spacer(modifier = Modifier.width(6.dp)) + Text(translation["open_settings_button"]) } } } @@ -574,101 +637,224 @@ class HomeRootSection : Routes.Route() { modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(26.dp), color = Color.White.copy(alpha = 0.06f), - border = BorderStroke(1.dp, Color.White.copy(alpha = 0.10f)) + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.10f)), + tonalElevation = 0.dp, + shadowElevation = 0.dp ) { Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp) ) { - val androidContext = context.androidContext Button( - modifier = Modifier.weight(1f).height(44.dp), - onClick = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - androidContext.openLink("https://purrfectsnap.me", context.translation["toast_open_link_failed"]) - }, - colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color(0xFF1B152E)), - contentPadding = PaddingValues(horizontal = 12.dp) + modifier = Modifier.weight(1f), + onClick = onWebsiteClick, + colors = ButtonDefaults.buttonColors( + containerColor = Color.White, + contentColor = Color(0xFF1B152E) + ) ) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) { - Icon(Icons.Filled.Language, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(modifier = Modifier.width(8.dp)) - PurrfectMarqueeText( - text = "Site", - style = TextStyle(fontSize = 13.sp, fontWeight = FontWeight.Bold), - color = Color(0xFF1B152E) - ) - } + Icon(Icons.Filled.Language, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = "Site", maxLines = 1, overflow = TextOverflow.Ellipsis) } OutlinedButton( - modifier = Modifier.weight(1f).height(44.dp), - onClick = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - androidContext.openLink("https://github.com/particle-box/PurrfectSnap", context.translation["toast_open_link_failed"]) - }, + modifier = Modifier.weight(1f), + onClick = onGithubClick, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.35f)), - colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White), - contentPadding = PaddingValues(horizontal = 12.dp) + colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White) ) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) { - Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_github), contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(modifier = Modifier.width(8.dp)) - PurrfectMarqueeText( - text = translation["github_button"], - style = TextStyle(fontSize = 13.sp, fontWeight = FontWeight.Bold), - color = Color.White - ) - } + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_github), + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text(text = translation["github_button"], maxLines = 1, overflow = TextOverflow.Ellipsis) } - ExternalLinkIcon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_telegram), onClick = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - androidContext.openLink("https://t.me/purrfectsnap_official", context.translation["toast_open_link_failed"]) - }, tint = Color.White, containerColor = Color.White.copy(alpha = 0.14f), haptic = haptic) + ExternalLinkIcon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_telegram), + onClick = onTelegramClick, + tint = Color.White, + containerColor = Color.White.copy(alpha = 0.14f) + ) } } } } } + private fun resolveTileKey(name: String): String { + val entry = cardEntries.firstOrNull { it.name == name } + return entry?.id ?: name + } + private fun getTileSpan(name: String): Pair { + val prefs = context.sharedPreferences + val key = resolveTileKey(name) + val raw = prefs.getString("quick_tile_size_$key", null) ?: "1x1" + val parts = raw.split('x') + val w = parts.getOrNull(0)?.toIntOrNull()?.coerceIn(1, 3) ?: 1 + val h = parts.getOrNull(1)?.toIntOrNull()?.coerceIn(1, 3) ?: 1 + return w to h + } + private fun setTileSpan(name: String, w: Int, h: Int) { + val prefs = context.sharedPreferences + val key = resolveTileKey(name) + prefs.edit().putString("quick_tile_size_$key", "${w.coerceIn(1,3)}x${h.coerceIn(1,3)}").apply() + } + private fun clearTileSpan(name: String) { + val prefs = context.sharedPreferences + val key = resolveTileKey(name) + prefs.edit().remove("quick_tile_size_$key").apply() + } + private fun clearTileOffset(name: String) { + val prefs = context.sharedPreferences + val key = resolveTileKey(name) + prefs.edit().remove("quick_tile_offset_$key").apply() + } + + override val title: @Composable (() -> Unit)? = {} override val init: () -> Unit = { activityLauncherHelper = ActivityLauncherHelper(context.activity!!) } + override val topBarActions: @Composable (RowScope.() -> Unit) = { + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Spacer(modifier = Modifier.weight(1f)) + HomeActionChips() + } + } - @OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) + + @OptIn(ExperimentalLayoutApi::class, ExperimentalAnimationApi::class, ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) override val content: @Composable (NavBackStackEntry) -> Unit = { - val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current - val avenirNext = remember { FontFamily(Font(R.font.avenir_next_medium, FontWeight.Medium)) } - val cards = rememberCards() - val selectedTiles = rememberAsyncMutableStateList(defaultValue = listOf()) { context.database.getQuickTiles().filter { it.isNotBlank() } } + val avenirNext = remember { + FontFamily(Font(R.font.avenir_next_medium, FontWeight.Medium)) + } + val prefs = remember { context.sharedPreferences } + val allQuickTileNames = remember(cards) { cards.keys.map { it.first } } + val selectedTiles = rememberAsyncMutableStateList(defaultValue = allQuickTileNames) { + val storedTiles = context.database.getQuickTiles().filter { it.isNotBlank() } + val hasInitializedQuickTiles = prefs.getBoolean(QUICK_TILES_INITIALIZED_PREF, false) + when { + storedTiles.isNotEmpty() -> { + if (!hasInitializedQuickTiles) { + prefs.edit().putBoolean(QUICK_TILES_INITIALIZED_PREF, true).apply() + } + storedTiles + } + hasInitializedQuickTiles -> storedTiles + else -> { + context.database.setQuickTiles(allQuickTileNames) + prefs.edit().putBoolean(QUICK_TILES_INITIALIZED_PREF, true).apply() + allQuickTileNames + } + } + } val updateChannel = context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable" val channelLabel = if (updateChannel == "prerelease") translation["channel_label_prerelease"] else translation["channel_label_stable"] - val latestUpdate by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(updateChannel)) { Updater.getLatestRelease(if (updateChannel == "prerelease") Channel.PRERELEASE else Channel.STABLE) } + val latestUpdate by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(updateChannel)) { + val channel = if (updateChannel == "prerelease") Channel.PRERELEASE else Channel.STABLE + Updater.getLatestRelease(channel) + } + val changelogUrl = if (updateChannel == "prerelease") changelogPrereleaseUrl else changelogStableUrl val downloadState by UpdateDownloader.downloadState.collectAsState() val downloadProgress by UpdateDownloader.downloadProgress.collectAsState() - val isPurrAuraActive by rememberPreferenceBool("debug_test_mode", true) - val scrollState = rememberScrollState() - var showQuickActionsMenu by rememberSaveable { mutableStateOf(false) } - var showChangelogDialog by rememberSaveable { mutableStateOf(false) } - var showAnnouncementsDialog by rememberSaveable { mutableStateOf(false) } - var announcementsText by rememberSaveable { mutableStateOf(null) } - var announcementsLoading by remember { mutableStateOf(false) } val coroutineScope = rememberCoroutineScope() - var controlsHeight by remember { mutableStateOf(100.dp) } - - LaunchedEffect(scrollState.value) { routes.navigation?.globalScrollOffset = scrollState.value } + val isPurrAuraActive by rememberPreferenceBool("debug_test_mode", true) + var showChangelogDialog by remember { mutableStateOf(false) } + var changelogLoading by remember { mutableStateOf(false) } + var changelogError by remember { mutableStateOf(null) } + var changelogText by remember { mutableStateOf(null) } + var changelogVersion by remember { mutableStateOf(null) } + var showAnnouncementsDialog by remember { mutableStateOf(false) } + var announcementsLoading by remember { mutableStateOf(false) } + var announcementsError by remember { mutableStateOf(null) } + var announcementsText by remember { mutableStateOf(null) } val handleUpdateAction: () -> Unit = { latestUpdate?.let { latest -> - val abiName = android.os.Build.SUPPORTED_ABIS.firstNotNullOfOrNull { when(it) { "arm64-v8a" -> "arm64"; "armeabi-v7a" -> "armv7"; else -> null } } - if (latest.workflowId != null) { - if (abiName != null) { - // Use workflowId to ensure the specific build is downloaded via nightly.link - val artifactName = "purrfectsnap-${if (abiName == "arm64") "armv8" else "armv7"}-debug" - UpdateDownloader.downloadAndInstall(context, "https://nightly.link/particle-box/PurrfectSnap/actions/runs/${latest.workflowId}/$artifactName.zip", "$artifactName.zip", coroutineScope) + val supportedAbis = android.os.Build.SUPPORTED_ABIS + var abiName: String? = null + for (abi in supportedAbis) { + when (abi) { + "arm64-v8a" -> { + abiName = "arm64" + break + } + "armeabi-v7a" -> { + abiName = "armv7" + break + } } + } + context.log.info( + "Update request: device ABIs=${supportedAbis.joinToString()} resolvedArch=${abiName ?: "unknown"}", + "HomeRoot" + ) + + if (latest.workflowId != null) { + if (abiName == null) { + android.widget.Toast.makeText( + context.androidContext, + translation["update_arch_not_supported_toast"], + android.widget.Toast.LENGTH_LONG + ).show() + } else { + val artifactName = "purrfectsnap-${abiName}-debug" + val downloadUrl = "https://nightly.link/particle-box/PurrfectSnap/actions/runs/${latest.workflowId}/$artifactName.zip" + context.log.info("Debug update -> downloading $artifactName from $downloadUrl", "HomeRoot") + UpdateDownloader.downloadAndInstall(context, downloadUrl, "$artifactName.zip", coroutineScope) + } + return@let + } + + val releaseDownload = abiName?.let { arch -> latest.assetDownloads[arch] } + if (releaseDownload != null) { + val fileName = releaseDownload.substringAfterLast('/') + context.log.info("Release update -> arch=$abiName url=$releaseDownload file=$fileName", "HomeRoot") + UpdateDownloader.downloadAndInstall(context, releaseDownload, fileName, coroutineScope) } else { - abiName?.let { arch -> latest.assetDownloads[arch] }?.let { url -> UpdateDownloader.downloadAndInstall(context, url, url.substringAfterLast('/'), coroutineScope) } + context.log.warn( + "No matching update asset for arch=$abiName (available: ${latest.assetDownloads.keys})", + "HomeRoot" + ) + context.androidContext.openLink( + latest.releaseUrl, + context.translation["toast_open_link_failed"] + ) + } + } + } + + fun loadChangelog(targetVersion: String, url: String) { + if (changelogVersion == targetVersion && changelogText != null) return + changelogLoading = true + changelogError = null + coroutineScope.launch(Dispatchers.IO) { + runCatching { + changelogClient.newCall(Request.Builder().url(url).build()).execute().use { response -> + if (!response.isSuccessful) throw IllegalStateException("Failed to fetch changelog (${response.code})") + val body = response.body?.string() ?: throw IllegalStateException("Empty changelog body") + extractChangelogForVersion(body, targetVersion).ifBlank { body.trim() } + } + }.onSuccess { text -> + withContext(Dispatchers.Main) { + changelogText = text + changelogVersion = targetVersion + changelogLoading = false + } + }.onFailure { error -> + withContext(Dispatchers.Main) { + changelogError = error.message ?: "Failed to load changelog" + changelogLoading = false + } } } } @@ -676,277 +862,322 @@ class HomeRootSection : Routes.Route() { fun loadAnnouncements() { if (announcementsText != null) return announcementsLoading = true + announcementsError = null coroutineScope.launch(Dispatchers.IO) { - runCatching { OkHttpClient().newCall(Request.Builder().url(announcementsUrl).build()).execute().use { it.body?.string() ?: "" } } - .onSuccess { withContext(Dispatchers.Main) { announcementsText = it; announcementsLoading = false } } - .onFailure { withContext(Dispatchers.Main) { announcementsLoading = false } } - } - } - - val borderPath = remember { Path() } - val uPath = remember { Path() } - - Box(modifier = Modifier.fillMaxSize().background(pageBackgroundGradient)) { - // FLOATING HEADER OVERLAY - val focusFactor by remember(scrollState.value) { - derivedStateOf { (scrollState.value.toFloat() / Motion.HEADER_MORPH_THRESHOLD).coerceIn(0f, 1f) } - } - val stickyBrandingAlpha by remember(scrollState.value) { - derivedStateOf { ((scrollState.value.toFloat() - 50f) / 100f).coerceIn(0f, 1f) } - } - val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() - - // Standard header dimensions and layout constants - val headerHeight = lerp(54.dp, 56.dp, focusFactor) - val sidePadding = 0.dp - val containerTopPadding = lerp(statusBarHeight + 2.dp, 0.dp, focusFactor) - val internalTopPadding = lerp(0.dp, statusBarHeight, focusFactor) - val internalVerticalPadding = 0.dp - val topCorners = lerp(26.dp, 0.dp, focusFactor) - val bottomCorners = lerp(26.dp, 28.dp, focusFactor) - - // Header Container - Box(modifier = Modifier.fillMaxWidth().zIndex(10f)) { - // Refractive background layer - val refractiveColor = remember { Color(0xFF241F52) } - Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = sidePadding) - .padding(top = containerTopPadding) - .height(internalTopPadding + 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 - ) - ) - ) - - // Sticky background surface for floating header - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = sidePadding) - .padding(top = containerTopPadding) - .headerHeightTracker { controlsHeight = it } - .drawBehind { - val strokeWidth = 1.dp.toPx() - val brush = Brush.linearGradient( - listOf( - PurrfectPalette.glowPrimary.copy(alpha = focusFactor * 0.6f), - PurrfectPalette.glowSecondary.copy(alpha = focusFactor * 0.4f) - ) - ) - val tr = topCorners.toPx() - val br = 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 if (focusFactor > 0.01f) { - 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)) - } - }, - shape = RoundedCornerShape( - topStart = topCorners, topEnd = topCorners, - bottomStart = bottomCorners, bottomEnd = bottomCorners - ), - color = Color(0xFF1B152E).copy(alpha = focusFactor * 0.95f) - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(top = internalTopPadding) - .padding(horizontal = 16.dp, vertical = internalVerticalPadding) - .height(headerHeight) - ) { - // Branding text visible when header is sticky - Text( - text = "PurrfectSnap", - color = Color.White.copy(alpha = stickyBrandingAlpha), - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - fontFamily = avenirNext, - modifier = Modifier.align(Alignment.Center) - ) - - // Left-aligned announcement interaction chip - val announcementShift by remember(focusFactor) { - derivedStateOf { (-6 * focusFactor).dp } - } - Row( - modifier = Modifier - .align(Alignment.CenterStart) - .graphicsLayer { - translationX = announcementShift.toPx() - }, - verticalAlignment = Alignment.CenterVertically - ) { - TopBarActionChip( - icon = Icons.Filled.Notifications, - label = null, - shrinkFactor = (1f - focusFactor).coerceIn(0f, 1f), - contentDescription = translation["announcements_button_description"], - haptic = haptic - ) { - showAnnouncementsDialog = true - loadAnnouncements() - } - } - - // Right-aligned action chips for system navigation - val settingsShift by remember(focusFactor) { - derivedStateOf { (6 * focusFactor).dp } - } - Row( - modifier = Modifier - .align(Alignment.CenterEnd) - .graphicsLayer { - translationX = settingsShift.toPx() - }, - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically - ) { - HomeActionChips(scrollState = scrollState, haptic = haptic) - } + runCatching { + changelogClient.newCall(Request.Builder().url(announcementsUrl).build()).execute().use { response -> + if (!response.isSuccessful) throw IllegalStateException("Failed to fetch announcements (${response.code})") + val body = response.body?.string() ?: throw IllegalStateException("Empty announcements body") + body.trim() + } + }.onSuccess { text -> + withContext(Dispatchers.Main) { + announcementsText = text + announcementsLoading = false + } + }.onFailure { error -> + withContext(Dispatchers.Main) { + announcementsError = error.message ?: "Failed to load announcements" + announcementsLoading = false } } } + } - Column(modifier = Modifier.fillMaxSize().verticalScroll(scrollState).padding(bottom = routes.bottomPadding + 12.dp)) { - Spacer(Modifier.height(controlsHeight + containerTopPadding)) - + LaunchedEffect(Unit) { + if (context.sharedPreferences.getBoolean("show_changelog_on_launch", false)) { + val version = context.sharedPreferences.getString("changelog_version_on_launch", null) + context.sharedPreferences.edit() + .putBoolean("show_changelog_on_launch", false) + .remove("changelog_version_on_launch") + .apply() + version?.let { + showChangelogDialog = true + loadChangelog(it, changelogUrl) + } + } + } + + LaunchedEffect(Unit) { + if (context.sharedPreferences.getBoolean("show_announcements_on_launch", false)) { + context.sharedPreferences.edit() + .putBoolean("show_announcements_on_launch", false) + .apply() + showAnnouncementsDialog = true + loadAnnouncements() + } + } + + val onUpdateButtonClick: () -> Unit = { + latestUpdate?.let { + showChangelogDialog = true + loadChangelog(it.versionName, changelogUrl) + } + } + + var showQuickActionsMenu by remember { mutableStateOf(false) } + val scrollState = rememberScrollState() + val statusBarPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + val navigationBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + val contentBottomPadding = routes.bottomPadding + navigationBarPadding + 96.dp + + Box( + modifier = Modifier + .fillMaxSize() + .background(pageBackgroundGradient) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(bottom = contentBottomPadding) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(WindowInsets.statusBars.asPaddingValues()) + .padding(horizontal = cardMargin, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TopBarActionChip( + icon = Icons.Filled.Notifications, + label = null, + contentDescription = translation["announcements_button_description"] + ) { + showAnnouncementsDialog = true + loadAnnouncements() + } + } + Row( + modifier = Modifier.wrapContentWidth(Alignment.End), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + HomeActionChips() + } + } + Spacer(modifier = Modifier.height(12.dp)) HeroSection( versionName = BuildConfig.VERSION_NAME, latestUpdate = latestUpdate, downloadState = downloadState, downloadProgress = downloadProgress, - onUpdateAction = { latestUpdate?.let { showChangelogDialog = true } }, + onUpdateAction = onUpdateButtonClick, channelLabel = channelLabel, isPurrAuraActive = isPurrAuraActive, - onAboutClick = { routes.about.navigate() }, + onWebsiteClick = { + context.androidContext.openLink( + "https://purrfectsnap.vercel.app/", + context.translation["toast_open_link_failed"] + ) + }, + onTelegramClick = { + context.androidContext.openLink( + "https://t.me/purrfectsnap_official", + context.translation["toast_open_link_failed"] + ) + }, + onGithubClick = { + context.androidContext.openLink( + "https://github.com/particle-box/PurrfectSnap", + context.translation["toast_open_link_failed"] + ) + }, + authorName = "ETERNAL", + onManageClick = { routes.settings.navigate() }, avenirNext = avenirNext, - scrollOffset = { scrollState.value }, - haptic = haptic ) - - Spacer(Modifier.height(12.dp)) - AnimatedContent(targetState = selectedTiles.isNotEmpty(), label = "QuickActions") { hasQuickActions -> - Surface(modifier = Modifier.padding(horizontal = cardMargin, vertical = 10.dp), shape = RoundedCornerShape(34.dp), color = Color.Transparent, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.05f))) { - Column(modifier = Modifier.fillMaxWidth().background(Brush.linearGradient(quickActionsGradientColors)).padding(horizontal = 24.dp, vertical = 28.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Spacer(modifier = Modifier.height(12.dp)) + AnimatedContent(targetState = selectedTiles.isNotEmpty(), label = "QuickActionsAnim") { hasQuickActions -> + val quickCardShape = RoundedCornerShape(34.dp) + Surface( + modifier = Modifier + .padding(horizontal = cardMargin, vertical = 10.dp), + shape = quickCardShape, + tonalElevation = 0.dp, + shadowElevation = 24.dp, + color = Color.Transparent, + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.05f)) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(Brush.linearGradient(quickActionsGradientColors)) + .padding(horizontal = 24.dp, vertical = 28.dp) + .padding(bottom = navigationBarPadding + 32.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { if (!hasQuickActions) { - Text(translation["quick_actions_title"], fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = Color.White.copy(alpha = 0.85f)) - Spacer(Modifier.height(24.dp)) - Icon(Icons.Outlined.Widgets, contentDescription = null, modifier = Modifier.size(72.dp), tint = Color.White) - Spacer(Modifier.height(16.dp)) - Text(translation["quick_actions_empty_title"], fontSize = 20.sp, fontWeight = FontWeight.Bold, color = Color.White) - Spacer(Modifier.height(20.dp)) - Button(onClick = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - showQuickActionsMenu = true - }, colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color(0xFF1B152E))) { - Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(20.dp)) - Spacer(Modifier.width(6.dp)) - Text(translation["quick_actions_add_tile_button"]) + Text( + translation["quick_actions_title"], + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White.copy(alpha = 0.85f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(24.dp)) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = Icons.Outlined.Widgets, + contentDescription = translation["quick_actions_icon_description"], + modifier = Modifier.size(72.dp), + tint = Color.White + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = translation["quick_actions_empty_title"], + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = Color.White + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = translation["quick_actions_empty_subtitle"], + fontSize = 14.sp, + color = Color.White.copy(alpha = 0.75f), + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(20.dp)) + Button( + onClick = { showQuickActionsMenu = true }, + colors = ButtonDefaults.buttonColors( + containerColor = Color.White, + contentColor = Color(0xFF1B152E) + ) + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = translation["add_quick_action_description"], + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text(text = translation["quick_actions_add_tile_button"]) + } } } else { - Column(modifier = Modifier.fillMaxWidth().padding(bottom = 18.dp), horizontalAlignment = Alignment.CenterHorizontally) { - Text(translation["quick_actions_title"], fontSize = 24.sp, fontWeight = FontWeight.Bold, color = Color.White) - Text(translation.format("quick_actions_count_label", "count" to selectedTiles.size.toString()), fontSize = 13.sp, color = Color.White.copy(alpha = 0.75f)) - Spacer(Modifier.height(12.dp)) - OutlinedButton(onClick = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - showQuickActionsMenu = true - }, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)), colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White)) { - Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_manage), contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(modifier = Modifier.width(6.dp)) - Text(translation["quick_actions_manage_button"]) - } - } - - var gridIsVisible by remember { mutableStateOf(false) } - var animationPhase by remember { mutableIntStateOf(1) } - - LaunchedEffect(gridIsVisible) { - if (gridIsVisible) { - delay(600) // Initial wait - animationPhase = 2 // Icons shrink & Text expands to 2 lines - delay(1200) // Distinct wait for expansion to finish - animationPhase = 3 // Enable Marquee scrolling - } - } - - BoxWithConstraints( + Column( modifier = Modifier .fillMaxWidth() - .onGloballyPositioned { coordinates -> - val windowHeight = context.androidContext.resources.displayMetrics.heightPixels - val positionInWindow = coordinates.localToWindow(Offset.Zero).y - // Trigger when the grid reaches the bottom area of the screen - if (positionInWindow > 0 && positionInWindow < windowHeight * 0.95f) { - gridIsVisible = true - } - } + .padding(bottom = 18.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { - val columns = (maxWidth / 110.dp).toInt().coerceIn(2, 4) - FlowRow(modifier = Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.SpaceEvenly, verticalArrangement = Arrangement.spacedBy(12.dp), maxItemsInEachRow = columns) { - selectedTiles.forEachIndexed { index, name -> - val cardEntry = cards.entries.find { it.key.first == name } ?: return@forEachIndexed - val interactionSource = remember { MutableInteractionSource() } - - val animatedIconSize by animateDpAsState( - targetValue = if (animationPhase >= 2) 28.dp else 44.dp, - animationSpec = spring(dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessLow), - label = "iconShrink" + Text( + translation["quick_actions_title"], + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + color = Color.White, + maxLines = 3, + overflow = TextOverflow.Clip + ) + Text( + text = translation.format("quick_actions_count_label", "count" to selectedTiles.size.toString()), + fontSize = 13.sp, + color = Color.White.copy(alpha = 0.75f), + textAlign = TextAlign.Center + ) + Row( + modifier = Modifier.wrapContentWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedButton( + onClick = { showQuickActionsMenu = true }, + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)), + colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White) + ) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_manage), + contentDescription = translation["manage_quick_actions_description"], + modifier = Modifier.size(18.dp) ) - + Spacer(modifier = Modifier.width(6.dp)) + Text(text = translation["quick_actions_manage_button"]) + } + } + } + val spacing = 12.dp + val gridPadding = 8.dp + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + val preferredTileWidth = 100.dp + val columns = ((maxWidth + spacing) / (preferredTileWidth + spacing)) + .toInt() + .coerceAtLeast(2) + .coerceAtMost(4) + val computedWidth = (maxWidth - gridPadding * 2 - spacing * (columns - 1)) / columns + val tileWidth = if (computedWidth < preferredTileWidth) computedWidth else preferredTileWidth + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(all = gridPadding), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalArrangement = Arrangement.spacedBy(spacing), + maxItemsInEachRow = columns + ) { + selectedTiles.forEach { tileName -> + val cardEntry = cards.entries.find { entry -> entry.key.first == tileName } ?: return@forEach + val (card, action) = cardEntry + val interactionSource = remember { MutableInteractionSource() } Surface( modifier = Modifier - .width(100.dp) - .aspectRatio(1.05f) // Restored to 1.05f for square look + .width(tileWidth) + .aspectRatio(1.05f) .scaleOnPress(interactionSource) - .clickable { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - cardEntry.value(routes) - }, - shape = RoundedCornerShape(18.dp), - color = Color.White.copy(alpha = 0.06f), + .clickable { action(routes) }, + shape = RoundedCornerShape(18.dp), + color = Color.White.copy(alpha = 0.06f), + tonalElevation = 0.dp, + shadowElevation = 0.dp, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f)) ) { - Box(Modifier.fillMaxSize().background(Brush.linearGradient(listOf(PurrfectPalette.glowPrimary.copy(alpha = 0.3f), PurrfectPalette.glowSecondary.copy(alpha = 0.22f)))).clipToBounds()) { - Column(modifier = Modifier.fillMaxSize().padding(horizontal = 8.dp, vertical = 10.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { - Icon(cardEntry.key.second, contentDescription = null, tint = Color.White, modifier = Modifier.size(animatedIconSize)) - Spacer(Modifier.height(8.dp)) + Box( + Modifier + .fillMaxSize() + .background( + Brush.linearGradient( + listOf( + PurrfectPalette.glowPrimary.copy(alpha = 0.3f), + PurrfectPalette.glowSecondary.copy(alpha = 0.22f) + ) + ) + ) + .clipToBounds() + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(all = 10.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = card.second, contentDescription = null, + tint = Color.White, + modifier = Modifier.size(44.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) Text( - text = cardEntry.key.first, - style = TextStyle(lineHeight = 15.sp, fontSize = 12.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center), + text = card.first, + lineHeight = 16.sp, + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, color = Color.White, - maxLines = if (animationPhase >= 2) 2 else 1, overflow = TextOverflow.Ellipsis, - softWrap = true, - modifier = Modifier.fillMaxWidth() + maxLines = 2, ) } } @@ -954,51 +1185,180 @@ class HomeRootSection : Routes.Route() { } } } - } } } } + Spacer(modifier = Modifier.height(32.dp)) } } + if (showChangelogDialog && latestUpdate != null) { + AestheticDialog( + onDismissRequest = { showChangelogDialog = false }, + title = translation["changelog_dialog_title"], + text = "", + icon = Icons.Filled.Info, + confirmButtonText = translation["changelog_dialog_update_button"], + onConfirm = { + showChangelogDialog = false + handleUpdateAction() + }, + dismissButtonText = translation["changelog_dialog_cancel_button"], + onDismiss = { showChangelogDialog = false }, + confirmEnabled = !changelogLoading, + showCloseButton = false, + customContent = { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 120.dp, max = 340.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + when { + changelogLoading -> { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator( + modifier = Modifier.size(28.dp), + strokeWidth = 3.dp, + color = Color.White + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = translation["changelog_dialog_loading"], + color = Color.White, + fontWeight = FontWeight.SemiBold + ) + } + } + + changelogError != null -> { + Text( + text = changelogError ?: translation["changelog_dialog_error"], + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.SemiBold + ) + } + + else -> { + Text( + text = changelogText ?: translation["changelog_dialog_empty"], + color = PurrfectPalette.textPrimary, + fontSize = 14.sp, + lineHeight = 20.sp + ) + } + } + } + } + ) + } + if (showAnnouncementsDialog) { AestheticDialog( - onDismissRequest = { showAnnouncementsDialog = false }, - title = translation["announcements_dialog_title"] ?: "Announcements", - text = "", - icon = Icons.Filled.Notifications, - confirmButtonText = translation["announcements_dialog_close_button"] ?: "Close", - onConfirm = { showAnnouncementsDialog = false }, + onDismissRequest = { showAnnouncementsDialog = false }, + title = translation["announcements_dialog_title"], + text = "", + icon = Icons.Filled.Info, + confirmButtonText = translation["announcements_dialog_close_button"], + onConfirm = { showAnnouncementsDialog = false }, + confirmEnabled = !announcementsLoading, + showCloseButton = false, customContent = { - Column(modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) { - if (announcementsLoading) CircularProgressIndicator(color = Color.White) - else Text(announcementsText ?: translation["announcements_dialog_empty"] ?: "No announcements", color = PurrfectPalette.textPrimary, fontSize = 14.sp) + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 120.dp, max = 340.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + when { + announcementsLoading -> { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator( + modifier = Modifier.size(28.dp), + strokeWidth = 3.dp, + color = Color.White + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = translation["announcements_dialog_loading"], + color = Color.White, + fontWeight = FontWeight.SemiBold + ) + } + } + + announcementsError != null -> { + Text( + text = announcementsError ?: translation["announcements_dialog_error"], + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.SemiBold + ) + } + + else -> { + Text( + text = announcementsText ?: translation["announcements_dialog_empty"], + color = PurrfectPalette.textPrimary, + fontSize = 14.sp, + lineHeight = 20.sp + ) + } + } } } ) } - - if (showChangelogDialog) { - val haptic = LocalHapticFeedback.current - AestheticDialog( - onDismissRequest = { showChangelogDialog = false }, - title = translation["changelog_dialog_title"], - text = latestUpdate?.body ?: translation["changelog_dialog_empty"], - icon = Icons.Filled.Info, - confirmButtonText = translation["changelog_dialog_update_button"], - onConfirm = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - showChangelogDialog = false - handleUpdateAction() - }, - dismissButtonText = translation["changelog_dialog_cancel_button"], - onDismiss = { showChangelogDialog = false } - ) - } if (showQuickActionsMenu) { - QuickActionsDialog(quickActions = cards, selectedQuickActions = selectedTiles, onDismiss = { showQuickActionsMenu = false }, onSave = { selectedTiles.clear(); selectedTiles.addAll(it); context.coroutineScope.launch { context.database.setQuickTiles(selectedTiles) }; showQuickActionsMenu = false }, translation = translation) + QuickActionsDialog( + quickActions = cards, + selectedQuickActions = selectedTiles, + onDismiss = { showQuickActionsMenu = false }, + onSave = { newList -> + val previous = selectedTiles.toList() + val removed = previous.filter { it !in newList } + removed.forEach { clearTileSpan(it); clearTileOffset(it) } + newList.forEach { clearTileOffset(it) } + selectedTiles.clear() + selectedTiles.addAll(newList) + prefs.edit().putBoolean(QUICK_TILES_INITIALIZED_PREF, true).apply() + context.coroutineScope.launch { + context.database.setQuickTiles(selectedTiles) + } + showQuickActionsMenu = false + }, + translation = translation + ) } } } +private fun extractChangelogForVersion(raw: String, version: String): String { + val lines = raw.lines() + val headerRegex = Regex("^\\s*#+\\s*v?${Regex.escape(version)}\\b", RegexOption.IGNORE_CASE) + val collected = mutableListOf() + var collecting = false + for (line in lines) { + if (!collecting) { + if (headerRegex.containsMatchIn(line)) { + collecting = true + } + continue + } + if (line.trimStart().startsWith("#")) break + collected.add(line) + } + return collected.joinToString("\n").trim() +} +} + diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeSettings.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeSettings.kt index f84b8d4a..d0246b01 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeSettings.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeSettings.kt @@ -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 - ) - } } } } +} diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/QuickActionsDialog.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/QuickActionsDialog.kt index 76796fac..31533424 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/QuickActionsDialog.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/QuickActionsDialog.kt @@ -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 diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/scripting/ManageScriptReposSection.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/scripting/ManageScriptReposSection.kt index 158f07d2..26107c5f 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/scripting/ManageScriptReposSection.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/scripting/ManageScriptReposSection.kt @@ -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) ) { diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/scripting/ScriptingRootSection.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/scripting/ScriptingRootSection.kt index bf1b1508..7e54f610 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/scripting/ScriptingRootSection.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/scripting/ScriptingRootSection.kt @@ -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(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) = {} } diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/ManageScope.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/ManageScope.kt index bff8f04b..a002d0d6 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/ManageScope.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/ManageScope.kt @@ -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) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/MessagingPreview.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/MessagingPreview.kt index 975fd00a..a7c1b9e7 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/MessagingPreview.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/MessagingPreview.kt @@ -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) { diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/SocialRootSection.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/SocialRootSection.kt index 612ca9b6..874349e1 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/SocialRootSection.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/SocialRootSection.kt @@ -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, - groups: List, - controlsHeight: androidx.compose.ui.unit.Dp + groups: List ) { 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 ) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/tracker/EditRule.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/tracker/EditRule.kt index b4657616..22f3a6be 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/tracker/EditRule.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/tracker/EditRule.kt @@ -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() diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/tracker/FriendTrackerCatalog.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/tracker/FriendTrackerCatalog.kt index 19194d3e..d765474c 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/tracker/FriendTrackerCatalog.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/tracker/FriendTrackerCatalog.kt @@ -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>(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) } } } diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/tracker/FriendTrackerManagerRoot.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/tracker/FriendTrackerManagerRoot.kt index 226e0ca2..78780f2c 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/tracker/FriendTrackerManagerRoot.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/tracker/FriendTrackerManagerRoot.kt @@ -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)) + } + } +} diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/tracker/LogsTab.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/tracker/LogsTab.kt index 6e3f7bf0..8bbdc23d 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/tracker/LogsTab.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/tracker/LogsTab.kt @@ -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(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 diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/tracker/ManageFriendTrackerReposSection.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/tracker/ManageFriendTrackerReposSection.kt index 7733ed68..38e1710b 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/tracker/ManageFriendTrackerReposSection.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/tracker/ManageFriendTrackerReposSection.kt @@ -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) ) { diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/overlay/RemoteOverlay.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/overlay/RemoteOverlay.kt index db0718e5..006bda56 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/overlay/RemoteOverlay.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/overlay/RemoteOverlay.kt @@ -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 } ) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/setup/screens/impl/SaveFolderScreen.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/setup/screens/impl/SaveFolderScreen.kt index 81b26900..332fc62d 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/setup/screens/impl/SaveFolderScreen.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/setup/screens/impl/SaveFolderScreen.kt @@ -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, diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/Animations.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/Animations.kt index 22da0db8..20eeb2df 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/Animations.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/Animations.kt @@ -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( - dampingRatio = Spring.DampingRatioLowBouncy, - stiffness = Spring.StiffnessMediumLow - ) - @Composable - fun tweenSpec(durationMillis: Int, easing: Easing = FastOutSlowInEasing): FiniteAnimationSpec { + fun tweenSpec(durationMillis: Int, easing: Easing = androidx.compose.animation.core.FastOutSlowInEasing): FiniteAnimationSpec { val reduced = rememberPrefersReducedMotion() val d = if (reduced) 0 else durationMillis - return tween(durationMillis = d, easing = easing) + return tween(durationMillis = d, easing = easing) } @Composable - fun tweenFloatSpec(durationMillis: Int, easing: Easing = FastOutSlowInEasing): FiniteAnimationSpec { + fun tweenFloatSpec(durationMillis: Int, easing: Easing = androidx.compose.animation.core.FastOutSlowInEasing): FiniteAnimationSpec { val reduced = rememberPrefersReducedMotion() val d = if (reduced) 0 else durationMillis - return tween(durationMillis = d, easing = easing) + return tween(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 diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/Modifiers.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/Modifiers.kt deleted file mode 100644 index 585359c5..00000000 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/Modifiers.kt +++ /dev/null @@ -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) - } -} diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/PurrfectText.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/PurrfectText.kt deleted file mode 100644 index e1bda729..00000000 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/PurrfectText.kt +++ /dev/null @@ -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 - ) - } -} diff --git a/app/src/main/res/drawable/launcher_icon_monochrome.xml b/app/src/main/res/drawable/launcher_icon_monochrome.xml new file mode 100644 index 00000000..d98ea0b7 --- /dev/null +++ b/app/src/main/res/drawable/launcher_icon_monochrome.xml @@ -0,0 +1,26 @@ + + + + + + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png b/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png index de24c6e4..d539a16b 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png and b/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png b/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png index cc754261..011ffc11 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png and b/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png differ diff --git a/app/src/main/res/mipmap-night-hdpi/ic_launcher_monochrome.png b/app/src/main/res/mipmap-night-hdpi/ic_launcher_monochrome.png deleted file mode 100644 index 48adb330..00000000 Binary files a/app/src/main/res/mipmap-night-hdpi/ic_launcher_monochrome.png and /dev/null differ diff --git a/app/src/main/res/mipmap-night-mdpi/ic_launcher_monochrome.png b/app/src/main/res/mipmap-night-mdpi/ic_launcher_monochrome.png deleted file mode 100644 index 011e8054..00000000 Binary files a/app/src/main/res/mipmap-night-mdpi/ic_launcher_monochrome.png and /dev/null differ diff --git a/app/src/main/res/mipmap-night-xhdpi/ic_launcher_monochrome.png b/app/src/main/res/mipmap-night-xhdpi/ic_launcher_monochrome.png deleted file mode 100644 index d6f62212..00000000 Binary files a/app/src/main/res/mipmap-night-xhdpi/ic_launcher_monochrome.png and /dev/null differ diff --git a/app/src/main/res/mipmap-night-xxhdpi/ic_launcher_monochrome.png b/app/src/main/res/mipmap-night-xxhdpi/ic_launcher_monochrome.png deleted file mode 100644 index 893a6407..00000000 Binary files a/app/src/main/res/mipmap-night-xxhdpi/ic_launcher_monochrome.png and /dev/null differ diff --git a/app/src/main/res/mipmap-night-xxxhdpi/ic_launcher_monochrome.png b/app/src/main/res/mipmap-night-xxxhdpi/ic_launcher_monochrome.png deleted file mode 100644 index 6b7db092..00000000 Binary files a/app/src/main/res/mipmap-night-xxxhdpi/ic_launcher_monochrome.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png index 60d194b7..a51f1164 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png and b/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png index 8d3b55d7..dfc99f66 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png index 3b1d3c2b..923e57bd 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png differ diff --git a/common/src/main/assets/lang/bn.json b/common/src/main/assets/lang/bn.json index 8626dae0..99e73e84 100644 --- a/common/src/main/assets/lang/bn.json +++ b/common/src/main/assets/lang/bn.json @@ -3237,7 +3237,7 @@ "username": "ইউজারনেম", "user_id": "ইউজার আইডি", "posted_on": "পোস্ট করা হয়েছে", - "loading_username": "লোড হচ্ছে\u2026", + "loading_username": "লোড হচ্ছে…", "username_copied": "ইউজারনেম কপি হয়েছে", "user_id_copied": "ইউজার আইডি কপি হয়েছে", "friend_status": "বন্ধু স্ট্যাটাস", diff --git a/common/src/main/assets/lang/de_DE.json b/common/src/main/assets/lang/de_DE.json index 1386078d..1c5b85a2 100644 --- a/common/src/main/assets/lang/de_DE.json +++ b/common/src/main/assets/lang/de_DE.json @@ -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 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 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?" }, diff --git a/common/src/main/assets/lang/en_UK.json b/common/src/main/assets/lang/en_UK.json index 17d2ed2f..1623f896 100644 --- a/common/src/main/assets/lang/en_UK.json +++ b/common/src/main/assets/lang/en_UK.json @@ -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 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 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", diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index c912c649..eecec4a0 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -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": "", "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 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 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" } } - - diff --git a/common/src/main/assets/lang/es_ES.json b/common/src/main/assets/lang/es_ES.json index efcab5bc..7768e849 100644 --- a/common/src/main/assets/lang/es_ES.json +++ b/common/src/main/assets/lang/es_ES.json @@ -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 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 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", diff --git a/common/src/main/assets/lang/fr_FR.json b/common/src/main/assets/lang/fr_FR.json index 4e97322c..ca8d6962 100644 --- a/common/src/main/assets/lang/fr_FR.json +++ b/common/src/main/assets/lang/fr_FR.json @@ -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 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 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", diff --git a/common/src/main/assets/lang/hu_HU.json b/common/src/main/assets/lang/hu_HU.json index 14b3fc65..eebe9377 100644 --- a/common/src/main/assets/lang/hu_HU.json +++ b/common/src/main/assets/lang/hu_HU.json @@ -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", diff --git a/common/src/main/assets/lang/it_IT.json b/common/src/main/assets/lang/it_IT.json index 5a18dbb5..260abaf6 100644 --- a/common/src/main/assets/lang/it_IT.json +++ b/common/src/main/assets/lang/it_IT.json @@ -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", diff --git a/common/src/main/assets/lang/ja_JP.json b/common/src/main/assets/lang/ja_JP.json index 1a43befe..8d0917e0 100644 --- a/common/src/main/assets/lang/ja_JP.json +++ b/common/src/main/assets/lang/ja_JP.json @@ -3236,7 +3236,7 @@ "username": "ユーザー名", "user_id": "ユーザーID", "posted_on": "投稿日", - "loading_username": "読み込み中\u2026", + "loading_username": "読み込み中…", "username_copied": "ユーザー名をコピーしました", "user_id_copied": "ユーザーIDをコピーしました", "friend_status": "フレンドステータス", diff --git a/common/src/main/assets/lang/ko_KR.json b/common/src/main/assets/lang/ko_KR.json index 89c50d6c..7fdcc236 100644 --- a/common/src/main/assets/lang/ko_KR.json +++ b/common/src/main/assets/lang/ko_KR.json @@ -3237,7 +3237,7 @@ "username": "사용자 이름", "user_id": "사용자 ID", "posted_on": "게시일", - "loading_username": "로드 중\u2026", + "loading_username": "로드 중…", "username_copied": "사용자 이름 복사됨", "user_id_copied": "사용자 ID 복사됨", "friend_status": "친구 상태", diff --git a/common/src/main/assets/lang/nl.json b/common/src/main/assets/lang/nl.json index f024c606..0cee8ab2 100644 --- a/common/src/main/assets/lang/nl.json +++ b/common/src/main/assets/lang/nl.json @@ -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",