diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/ManagerTheme.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/ManagerTheme.kt new file mode 100644 index 00000000..64851ac5 --- /dev/null +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/ManagerTheme.kt @@ -0,0 +1,25 @@ +package me.eternal.purrfectsnap.ui.manager + +import me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion.AphelionTheme +import me.eternal.purrfectsnap.ui.manager.pages.themes.legacy.LegacyTheme + +/** + * ManagerTheme is the registry of all available themes. + * + * Adding a new theme requires changes only in this file: + * - Add a new object entry to the sealed class + * - Add its ID string to fromId() + * + * No other file needs to change when a new theme is added. + */ +sealed class ManagerTheme(val theme: ThemeContract) { + object Legacy : ManagerTheme(LegacyTheme) + object Aphelion : ManagerTheme(AphelionTheme) + + companion object { + fun fromId(id: String): ManagerTheme = when (id) { + "APHELION" -> Aphelion + else -> Legacy // default fallback is always Legacy + } + } +} 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 4060bd06..7b8bb58c 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 @@ -39,6 +39,10 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState @@ -69,12 +73,7 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.RadioButtonDefaults import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -116,28 +115,43 @@ import kotlin.math.sin androidx.compose.animation.ExperimentalAnimationApi::class ) class Navigation( - private val context: RemoteSideContext, + internal val context: RemoteSideContext, private val navController: NavHostController, val routes: Routes = Routes(context).also { it.navController = navController } ) { 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 isAphelion = context.config.root.global.uiSettings.managerTheme.get() == "APHELION" + val focusFactor = if (isAphelion) (globalScrollOffset / shrinkThreshold).coerceIn(0f, 1f) else 0f + 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 + overflow = TextOverflow.Ellipsis, + modifier = Modifier.graphicsLayer { + scaleX = 1f - (focusFactor * 0.05f) + scaleY = 1f - (focusFactor * 0.05f) + translationY = (-2 * focusFactor).dp.toPx() + } ) } } @@ -146,11 +160,20 @@ class Navigation( val backButtonAnimation by animateFloatAsState(if (canGoBack) 1f else 0f, label = "backButton") Box( modifier = Modifier - .graphicsLayer { alpha = backButtonAnimation } + .graphicsLayer { + alpha = backButtonAnimation + scaleX = 1f - (focusFactor * 0.1f) + scaleY = 1f - (focusFactor * 0.1f) + } .width(lerp(0.dp, 48.dp, backButtonAnimation)) .height(48.dp) ) { - IconButton(onClick = { if (canGoBack) navController.popBackStack() }) { + IconButton(onClick = { + if (canGoBack) { + haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress) + navController.popBackStack() + } + }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) } } @@ -162,21 +185,34 @@ class Navigation( actions = { currentRoute?.topBarActions?.invoke(this) if (currentRoute?.routeInfo?.id == routes.settings.routeInfo.id) { - IconButton(onClick = { openBottomBarCustomization = true }) { + IconButton(onClick = { + haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress) + openBottomBarCustomization = true + }) { Icon(Icons.Filled.Tune, contentDescription = null) } } } ) } + @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 isAphelion = context.config.root.global.uiSettings.managerTheme.get() == "APHELION" + val focusFactor = if (isAphelion) (globalScrollOffset / shrinkThreshold).coerceIn(0f, 1f) else 0f + val barHeight = lerp(82.dp, 64.dp, focusFactor) + val labelAlpha = (1f - (focusFactor * 2.5f)).coerceIn(0f, 1f) + val iconTranslationY = (10 * focusFactor).dp + val prefs = remember { context.sharedPreferences } val defaultOrder = remember { listOf("tasks", "features", "home", "social", "scripts") } fun loadSelected(): List { @@ -233,9 +269,17 @@ class Navigation( val animatedBarWidth by animateDpAsState(targetValue = targetBarWidth ?: 0.dp, label = "barWidth") Surface( shape = barShape, - color = Color.Transparent, + color = Color.White.copy(alpha = 0.08f), contentColor = MaterialTheme.colorScheme.onSurface, - border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)), + border = BorderStroke( + 1.dp, + Brush.linearGradient( + listOf( + PurrfectPalette.glowPrimary.copy(alpha = 0.9f), + PurrfectPalette.glowSecondary.copy(alpha = 0.85f) + ) + ) + ), modifier = Modifier .then(if (targetBarWidth != null) Modifier.width(animatedBarWidth) else Modifier.fillMaxWidth()) .drawBehind { @@ -263,7 +307,7 @@ class Navigation( Box( Modifier .fillMaxWidth() - .height(82.dp) + .height(barHeight) .clip(barShape) .background(PurrfectPalette.cardOverlay) .border(BorderStroke(1.dp, barBorder), barShape) @@ -296,16 +340,16 @@ class Navigation( ) } ) - Box(Modifier.fillMaxWidth().height(82.dp)) { + Box(Modifier.fillMaxWidth().height(barHeight)) { var barWidthPx by remember { mutableStateOf(0f) } val itemCount = selectedRoutes.size.coerceAtLeast(1) val density = androidx.compose.ui.platform.LocalDensity.current val selectedIndex = remember(currentRoute, selectedRoutes) { val index = selectedRoutes.indexOf(currentRoute) - if (index >= 0) index else null // indexOf returns -1 when not found, replace with null + if (index >= 0) index else null } - selectedIndex?.let { // Null check + selectedIndex?.let { val itemWidthPx = remember(barWidthPx, itemCount) { if (itemCount > 0) barWidthPx / itemCount else 0f } val offsetAnim = remember { Animatable(0f) } @@ -369,7 +413,7 @@ class Navigation( .fillMaxHeight() .width(indicatorWidth.coerceAtLeast(0.dp)) .offset(x = offsetX) - .padding(vertical = 10.dp, horizontal = 2.dp) + .padding(vertical = lerp(10.dp, 8.dp, focusFactor), horizontal = 2.dp) .graphicsLayer { scaleX = scaleXAnim; scaleY = scaleYAnim } ) { Box( @@ -430,7 +474,10 @@ class Navigation( contentDescription = null, modifier = Modifier .size(22.dp + 2.dp * selectionProgress) - .graphicsLayer { alpha = 0.65f + 0.35f * selectionProgress } + .graphicsLayer { + alpha = 0.65f + 0.35f * selectionProgress + translationY = iconTranslationY.toPx() + } ) }, label = { @@ -441,11 +488,15 @@ class Navigation( textAlign = TextAlign.Center, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, - color = Color.White.copy(alpha = 0.6f + 0.4f * selectionProgress), + color = Color.White.copy(alpha = (0.6f + 0.4f * selectionProgress) * labelAlpha), maxLines = if (isLong) 2 else 1, overflow = if (isLong) TextOverflow.Ellipsis else TextOverflow.Clip, softWrap = isLong, - modifier = if (isLong) Modifier.widthIn(max = 90.dp).wrapContentWidth(Alignment.CenterHorizontally) else Modifier.wrapContentWidth(Alignment.CenterHorizontally) + modifier = (if (isLong) Modifier.widthIn(max = 90.dp).wrapContentWidth(Alignment.CenterHorizontally) else Modifier.wrapContentWidth(Alignment.CenterHorizontally)) + .graphicsLayer { + alpha = labelAlpha + translationY = (-10 * focusFactor).dp.toPx() + } ) }, selected = isSelected, @@ -456,7 +507,10 @@ class Navigation( unselectedTextColor = Color.White.copy(alpha = 0.72f), indicatorColor = Color.Transparent ), - onClick = { route.navigateReset() } + onClick = { + haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress) + route.navigateReset() + } ) } } @@ -545,7 +599,7 @@ class Navigation( modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) ) } else { - val haptic = LocalHapticFeedback.current + val hapticCustom = LocalHapticFeedback.current var draggingId by remember { mutableStateOf(null) } var dragDelta by remember { mutableStateOf(0f) } var dragStartIndex by remember { mutableStateOf(-1) } @@ -577,7 +631,7 @@ class Navigation( draggingId = id dragStartIndex = selectedTabIds.indexOf(id) dragDelta = 0f - haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress) + hapticCustom.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress) }, onDrag = { _: PointerInputChange, dragAmount -> dragDelta += dragAmount.y @@ -591,7 +645,7 @@ class Navigation( list.add(targetIndex, id) selectedTabIds = list saveSelected(selectedTabIds) - haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove) + hapticCustom.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove) } } }, @@ -663,7 +717,7 @@ class Navigation( .fillMaxWidth() .padding(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { + ) { availableRoutes.forEach { route -> val id = route.routeInfo.id val already = selectedTabIds.contains(id) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/ThemeContract.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/ThemeContract.kt new file mode 100644 index 00000000..8615a7a5 --- /dev/null +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/ThemeContract.kt @@ -0,0 +1,28 @@ +package me.eternal.purrfectsnap.ui.manager + +import androidx.compose.runtime.Composable +import androidx.navigation.NavBackStackEntry +import me.eternal.purrfectsnap.ui.manager.pages.TasksRootSection +import me.eternal.purrfectsnap.ui.manager.pages.features.FeaturesRootSection +import me.eternal.purrfectsnap.ui.manager.pages.home.HomeAbout +import me.eternal.purrfectsnap.ui.manager.pages.home.HomeRootSection +import me.eternal.purrfectsnap.ui.manager.pages.home.HomeSettings +import me.eternal.purrfectsnap.ui.manager.pages.home.HomeLogs +import me.eternal.purrfectsnap.ui.manager.pages.scripting.ScriptingRootSection +import me.eternal.purrfectsnap.ui.manager.pages.social.SocialRootSection +import me.eternal.purrfectsnap.ui.manager.pages.tracker.FriendTrackerManagerRoot + +/** + * ThemeContract defines the visual layout contract every theme must fulfill. + */ +interface ThemeContract { + @Composable fun HomeRootSection.HomeScreen(nav: NavBackStackEntry) + @Composable fun HomeSettings.SettingsScreen(nav: NavBackStackEntry) + @Composable fun HomeAbout.AboutScreen(nav: NavBackStackEntry) + @Composable fun HomeLogs.LogsScreen(nav: NavBackStackEntry) + @Composable fun SocialRootSection.SocialScreen(nav: NavBackStackEntry) + @Composable fun TasksRootSection.TasksScreen(nav: NavBackStackEntry) + @Composable fun FeaturesRootSection.FeaturesScreen(nav: NavBackStackEntry) + @Composable fun ScriptingRootSection.ScriptingScreen(nav: NavBackStackEntry) + @Composable fun FriendTrackerManagerRoot.FriendTrackerScreen(nav: NavBackStackEntry) +} 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 c7bdb76c..68c819da 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,56 +1,50 @@ package me.eternal.purrfectsnap.ui.manager.components import androidx.compose.foundation.BorderStroke -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.background +import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -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.material3.* +import androidx.compose.runtime.* 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 @Immutable data class FloatingTopBarColors( val container: Color, - val border: Brush + val borderStart: Color, + val borderEnd: Color ) @Composable fun rememberDefaultFloatingTopBarColors(): FloatingTopBarColors { return remember { FloatingTopBarColors( - container = Color.White.copy(alpha = 0.07f), - border = Brush.linearGradient( - listOf( - PurrfectPalette.glowPrimary.copy(alpha = 0.55f), - PurrfectPalette.glowSecondary.copy(alpha = 0.35f) - ) - ) + container = Color.White.copy(alpha = 0.12f), + borderStart = PurrfectPalette.glowPrimary.copy(alpha = 0.6f), + borderEnd = PurrfectPalette.glowSecondary.copy(alpha = 0.4f) ) } } @@ -61,65 +55,216 @@ fun FloatingTopBar( subtitle: String? = null, onBack: (() -> Unit)? = null, modifier: Modifier = Modifier, + scrollOffset: Int = 0, + containerAlpha: Float = 1f, + titleAlignment: Alignment.Horizontal = Alignment.Start, actions: @Composable RowScope.() -> Unit = {}, colors: FloatingTopBarColors = rememberDefaultFloatingTopBarColors() ) { - 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) + val haptic = LocalHapticFeedback.current + val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + + 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 + } + } + } + + 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)) { + 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 + ) + ) + ) + + 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 ) { - if (onBack != null) { - IconButton(onClick = onBack, modifier = Modifier.size(42.dp)) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - tint = Color.White + 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)) + ) + ) ) - } - } 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) { + .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)) + } + } + ) { Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = morphingParams.internalTopPadding) + .padding(horizontal = 16.dp, vertical = morphingParams.internalVerticalPadding) + .height(morphingParams.headerHeight), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - content = actions - ) + 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 = titleAlignment + ) { + Text( + text = title, + color = Color.White, + fontWeight = FontWeight.ExtraBold, + fontSize = 19.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = if (titleAlignment == Alignment.CenterHorizontally) TextAlign.Center else TextAlign.Start, + 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 = if (titleAlignment == Alignment.CenterHorizontally) TextAlign.Center else TextAlign.Start, + contentAlignment = if (titleAlignment == Alignment.CenterHorizontally) Alignment.Center else 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 + if (onBack != null) { + translationX = morphingParams.horizontalShift.toPx() + } + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + actions() + } + } } } } 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 7634317e..c9b824b0 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.background import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures @@ -42,9 +42,12 @@ import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.core.net.toUri import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.Lifecycle @@ -53,6 +56,7 @@ import coil.compose.rememberAsyncImagePainter import coil.request.ImageRequest import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import me.eternal.purrfectsnap.bridge.DownloadCallback import me.eternal.purrfectsnap.common.data.download.DownloadMetadata @@ -65,6 +69,7 @@ import me.eternal.purrfectsnap.download.DownloadProcessor import me.eternal.purrfectsnap.download.FFMpegProcessor import me.eternal.purrfectsnap.task.* import me.eternal.purrfectsnap.ui.manager.Routes +import me.eternal.purrfectsnap.ui.manager.ManagerTheme import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette import me.eternal.purrfectsnap.ui.util.OnLifecycleEvent import me.eternal.purrfectsnap.ui.util.coil.cacheKey @@ -74,17 +79,31 @@ import kotlin.math.absoluteValue import kotlin.text.Regex class TasksRootSection : Routes.Route() { - private var activeTasks by mutableStateOf(listOf()) - private lateinit var recentTasks: MutableList - private val taskSelection = mutableStateListOf>() + internal var activeTasks by mutableStateOf(listOf()) + internal lateinit var recentTasks: MutableList + internal var lastFetchedTaskId: Long? by mutableStateOf(null) + internal val taskSelection = mutableStateListOf>() - private fun fetchActiveTasks(scope: CoroutineScope = context.coroutineScope) { + internal fun isRecentTasksInitialized(): Boolean = ::recentTasks.isInitialized + + internal fun fetchActiveTasks(scope: CoroutineScope = context.coroutineScope) { scope.launch(Dispatchers.IO) { activeTasks = context.taskManager.getActiveTasks().values.sortedByDescending { it.taskId }.toMutableList() } } - private fun mergeSelection(selection: List>) { + internal fun fetchNewRecentTasks(scope: CoroutineScope = context.coroutineScope) { + scope.launch(Dispatchers.IO) { + val tasks = context.taskManager.fetchStoredTasks(lastFetchedTaskId ?: Long.MAX_VALUE, limit = 20) + if (tasks.isNotEmpty()) { + lastFetchedTaskId = tasks.keys.last() + val activeTaskIds = activeTasks.map { it.taskId } + recentTasks.addAll(tasks.filter { it.key !in activeTaskIds }.values) + } + } + } + + internal fun mergeSelection(selection: List>) { val firstTask = selection.first().first val taskHash = UUID.randomUUID().toString().longHashCode().absoluteValue.toString(16) @@ -105,7 +124,6 @@ class TasksRootSection : Routes.Route() { runCatching { pendingTask.updateProgress("Copying ${documentFile.name}") context.androidContext.contentResolver.openInputStream(documentFile.uri)?.use { inputStream -> - //copy with progress val length = documentFile.length().toFloat() tempFile.outputStream().use { outputStream -> val buffer = ByteArray(16 * 1024) @@ -164,7 +182,7 @@ class TasksRootSection : Routes.Route() { } } - private fun clearTasks(alsoDeleteFiles: Boolean, scope: CoroutineScope) { + internal fun clearTasks(alsoDeleteFiles: Boolean, scope: CoroutineScope) { if (taskSelection.isNotEmpty()) { taskSelection.forEach { (task, documentFile) -> scope.launch(Dispatchers.IO) { @@ -189,7 +207,7 @@ class TasksRootSection : Routes.Route() { } @Composable - private fun TaskDangerDialog( + internal fun TaskDangerDialog( visible: Boolean, title: String, message: String, @@ -219,124 +237,33 @@ class TasksRootSection : Routes.Route() { shadowElevation = 20.dp, border = BorderStroke(1.dp, borderGradient) ) { - Box( - modifier = Modifier - .background(PurrfectPalette.cardOverlay, dialogShape) - ) { - Column( - modifier = Modifier - .padding(horizontal = 20.dp, vertical = 18.dp), - verticalArrangement = Arrangement.spacedBy(14.dp) - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - 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)) - ) { - Box( - modifier = Modifier - .size(56.dp) - .background( - Brush.linearGradient( - listOf( - PurrfectPalette.glowPrimary.copy(alpha = 0.38f), - PurrfectPalette.glowSecondary.copy(alpha = 0.32f) - ) - ), - CircleShape - ), - contentAlignment = Alignment.Center - ) { - Icon( - Icons.Filled.DeleteOutline, - contentDescription = null, - tint = Color.White - ) + Box(modifier = Modifier.background(PurrfectPalette.cardOverlay, dialogShape)) { + Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + Surface(shape = CircleShape, color = Color.White.copy(alpha = 0.08f), border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { + Box(modifier = Modifier.size(56.dp).background(Brush.linearGradient(listOf(PurrfectPalette.glowPrimary.copy(alpha = 0.38f), PurrfectPalette.glowSecondary.copy(alpha = 0.32f)))), contentAlignment = Alignment.Center) { + Icon(imageVector = Icons.Filled.Warning, contentDescription = null, modifier = Modifier.size(28.dp), tint = Color.White) } } - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - text = title, - style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.ExtraBold), - color = Color.White - ) - Text( - text = message, - style = MaterialTheme.typography.bodyMedium, - color = PurrfectPalette.textSecondary - ) + Column { + Text(text = title, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = Color.White) + Text(text = message, fontSize = 13.sp, color = PurrfectPalette.textSecondary) } } if (showDeleteFiles) { - Surface( - shape = RoundedCornerShape(18.dp), - color = Color.White.copy(alpha = 0.04f), - tonalElevation = 0.dp, - shadowElevation = 0.dp, - border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onToggleDeleteFiles(!deleteFilesChecked) } - .padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { - Checkbox( - checked = deleteFilesChecked, - onCheckedChange = { onToggleDeleteFiles(it) }, - colors = CheckboxDefaults.colors( - checkedColor = PurrfectPalette.glowPrimary, - uncheckedColor = Color.White, - checkmarkColor = Color.Black - ) - ) - Column { - Text( - text = context.translation["manager.sections.tasks.delete_files_option"], - color = Color.White, - fontWeight = FontWeight.SemiBold - ) - Text( - text = context.translation["manager.sections.tasks.delete_files_option_hint"] - ?: "Also remove downloaded files", - color = PurrfectPalette.textSecondary, - style = MaterialTheme.typography.bodySmall - ) - } - } + Row(modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).background(Color.White.copy(alpha = 0.04f)).clickable { onToggleDeleteFiles(!deleteFilesChecked) }.padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { + Text(text = translation["clear_tasks_delete_files"], fontSize = 14.sp, color = Color.White.copy(alpha = 0.9f)) + Checkbox(checked = deleteFilesChecked, onCheckedChange = null, colors = CheckboxDefaults.colors(checkedColor = PurrfectPalette.glowPrimary, uncheckedColor = Color.White.copy(alpha = 0.3f), checkmarkColor = Color.White)) } } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End) - ) { - Button( - onClick = onDismiss, - colors = ButtonDefaults.buttonColors( - containerColor = Color.White.copy(alpha = 0.08f), - contentColor = Color.White - ) - ) { - Text(context.translation["button.negative"]) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Button(onClick = { onDismiss() }, modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors(containerColor = Color.White.copy(alpha = 0.08f), contentColor = Color.White), shape = RoundedCornerShape(14.dp)) { + Text(text = context.translation["button.cancel"]) } - Button( - onClick = onConfirm, - colors = ButtonDefaults.buttonColors( - containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f), - contentColor = Color.White - ) - ) { - Text(context.translation["button.positive"]) + Button(onClick = { onConfirm() }, modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color(0xFF1B152E)), shape = RoundedCornerShape(14.dp)) { + Text(text = context.translation["button.positive"], fontWeight = FontWeight.Bold) } } } @@ -346,106 +273,23 @@ class TasksRootSection : Routes.Route() { } @Composable - private fun TasksEmptyState(text: String) { + internal fun TasksEmptyState(text: String) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(top = 60.dp), + modifier = Modifier.fillMaxWidth().padding(top = 60.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = 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.12f)) - ) { - Box( - modifier = Modifier - .size(58.dp) - .background( - Brush.linearGradient( - listOf( - PurrfectPalette.glowPrimary.copy(alpha = 0.32f), - PurrfectPalette.glowSecondary.copy(alpha = 0.28f) - ) - ), - CircleShape - ), - contentAlignment = Alignment.Center - ) { - Icon( - Icons.Filled.CheckCircle, - contentDescription = text, - tint = Color.White - ) + Surface(shape = CircleShape, color = Color.White.copy(alpha = 0.08f), border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { + Box(modifier = Modifier.size(58.dp).background(Brush.linearGradient(listOf(PurrfectPalette.glowPrimary.copy(alpha = 0.32f), PurrfectPalette.glowSecondary.copy(alpha = 0.28f))), CircleShape), contentAlignment = Alignment.Center) { + Icon(Icons.Filled.CheckCircle, contentDescription = text, tint = Color.White) } } - Text( - text = text, - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.ExtraBold), - color = Color.White - ) - } - } - - 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 } - ) + Text(text = text, style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.ExtraBold), color = Color.White) } } @Composable - private fun TaskCard(modifier: Modifier, task: Task, pendingTask: PendingTask? = null) { + internal fun TaskCard(modifier: Modifier, task: Task, pendingTask: PendingTask? = null) { var taskStatus by remember { mutableStateOf(task.status) } var taskProgressLabel by remember { mutableStateOf(null) } var taskProgress by remember { mutableIntStateOf(-1) } @@ -460,32 +304,16 @@ class TasksRootSection : Routes.Route() { } } - val listener = remember { PendingTaskListener( - onStateChange = { - taskStatus = it - }, - onProgress = { label, progress -> - taskProgressLabel = label - taskProgress = progress - } + onStateChange = { taskStatus = it }, + onProgress = { label, progress -> taskProgressLabel = label; taskProgress = progress } ) } - LaunchedEffect(Unit) { - pendingTask?.addListener(listener) - } - - DisposableEffect(Unit) { - onDispose { - pendingTask?.removeListener(listener) - } - } + LaunchedEffect(Unit) { pendingTask?.addListener(listener) } + DisposableEffect(Unit) { onDispose { pendingTask?.removeListener(listener) } } fun toggleSelection() { - if (isSelected) { - taskSelection.removeIf { it.first == task } - return - } + if (isSelected) { taskSelection.removeIf { it.first == task }; return } taskSelection.add(task to documentFile) } @@ -506,27 +334,13 @@ class TasksRootSection : Routes.Route() { val cardModifier = modifier .pointerInput(Unit) { detectTapGestures( - onTap = { - if (taskSelection.isNotEmpty()) { - toggleSelection() - return@detectTapGestures - } - openFile() - }, - onLongPress = { - if (taskSelection.isNotEmpty()) { - openFile() - return@detectTapGestures - } - toggleSelection() - } + onTap = { if (taskSelection.isNotEmpty()) toggleSelection() else openFile() }, + onLongPress = { if (taskSelection.isNotEmpty()) openFile() else toggleSelection() } ) } .let { if (isSelected) { - it - .border(2.dp, PurrfectPalette.glowSecondary, MaterialTheme.shapes.large) - .clip(MaterialTheme.shapes.large) + it.border(2.dp, PurrfectPalette.glowSecondary, MaterialTheme.shapes.large).clip(MaterialTheme.shapes.large) } else it } @@ -544,498 +358,65 @@ class TasksRootSection : Routes.Route() { taskStatus == TaskStatus.CANCELLED -> Icons.Filled.Cancel else -> Icons.Filled.Info } - val chipColors = when { - isActive -> AssistChipDefaults.assistChipColors( - containerColor = Color.White.copy(alpha = 0.08f), - labelColor = Color.White - ) - taskStatus == TaskStatus.SUCCESS -> AssistChipDefaults.assistChipColors() - taskStatus == TaskStatus.FAILURE -> AssistChipDefaults.assistChipColors( - containerColor = Color(0xFFFF6B9B).copy(alpha = 0.18f), - labelColor = Color.White - ) - taskStatus == TaskStatus.CANCELLED -> AssistChipDefaults.assistChipColors( - containerColor = Color.White.copy(alpha = 0.06f), - labelColor = PurrfectPalette.textSecondary - ) - else -> AssistChipDefaults.assistChipColors() - } - val countdownText = if (isActive) { - taskProgressLabel?.let { label -> - Regex("""(\d+d\s+)?(\d+h\s+)?(\d+m\s+)?\d+s""").find(label)?.value?.trim() ?: label - } - } else null - val cardShape = RoundedCornerShape(22.dp) Surface( - modifier = cardModifier, - shape = cardShape, - color = Color.Transparent, + modifier = cardModifier.fillMaxWidth().padding(vertical = 4.dp), + shape = MaterialTheme.shapes.large, + color = Color.White.copy(alpha = 0.04f), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)), tonalElevation = 0.dp, - shadowElevation = if (isSelected) 12.dp else 6.dp, - border = BorderStroke( - 1.dp, - if (isSelected) Brush.linearGradient(listOf(PurrfectPalette.glowPrimary, PurrfectPalette.glowSecondary)) - else SolidColor(Color.White.copy(alpha = 0.1f)) - ) + shadowElevation = 0.dp ) { - Row( - modifier = Modifier - .background(PurrfectPalette.cardOverlay, cardShape) - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .padding(end = 15.dp) - .size(50.dp) - .clipToBounds(), - contentAlignment = Alignment.Center - ) { - var loadFailed by remember { mutableStateOf(false) } - documentFile?.let { - if (taskStatus.isFinalStage() && isDocumentFileReadable && !loadFailed && (documentFileMimeType.contains("image") || documentFileMimeType.contains("video"))) { - Image( - painter = rememberAsyncImagePainter( - model = ImageRequest.Builder(context.androidContext) - .data(it.uri) - .cacheKey(it.uri.toString()) - .placeholder(ColorDrawable(PurrfectPalette.cardOverlayColor.toArgb())) - .build(), - imageLoader = context.imageLoader, - onError = { loadFailed = true } - ), - contentDescription = null, - contentScale = ContentScale.FillWidth, - modifier = Modifier - .size(50.dp) - .clip(MaterialTheme.shapes.medium) - ) - } else { - when { - !isDocumentFileReadable -> Icon(Icons.Filled.DeleteOutline, contentDescription = "File not found") - 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") - } - } - } ?: run { - when (task.type) { - TaskType.DOWNLOAD -> Icon(Icons.Filled.Download, contentDescription = "Download") - TaskType.CHAT_ACTION -> Icon(Icons.Filled.ChatBubble, contentDescription = "Chat Action") - TaskType.SCHEDULED_SEND -> { - val isActive = !taskStatus.isFinalStage() - val rotation = if (isActive) { - val transition = rememberInfiniteTransition(label = "scheduled_send") - transition.animateFloat( - initialValue = 0f, - targetValue = 360f, - animationSpec = infiniteRepeatable(animation = tween(1200, easing = LinearEasing)), - label = "rotation" - ).value - } else 0f - Box( - modifier = Modifier - .size(50.dp) - .clip(CircleShape) - .background( - if (isActive) { - Brush.linearGradient( - colors = listOf( - PurrfectPalette.glowPrimary.copy(alpha = 0.25f), - PurrfectPalette.glowSecondary.copy(alpha = 0.22f) - ) - ) - } else { - Brush.linearGradient( - colors = listOf( - Color.White.copy(alpha = 0.06f), - Color.White.copy(alpha = 0.06f) - ) - ) - } - ), - contentAlignment = Alignment.Center - ) { - Icon( - Icons.Filled.Schedule, - contentDescription = "Scheduled Send", - modifier = Modifier - .size(28.dp) - .rotate(rotation), - tint = if (isActive) PurrfectPalette.glowSecondary else PurrfectPalette.textSecondary - ) - } - } - } + Row(modifier = Modifier.padding(14.dp).fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) { + Box(modifier = Modifier.size(54.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.06f)).border(1.dp, Color.White.copy(alpha = 0.1f), CircleShape), contentAlignment = Alignment.Center) { + Icon(imageVector = if (task.type == TaskType.DOWNLOAD) Icons.Default.Download else Icons.Default.Transform, contentDescription = null, tint = Color.White.copy(alpha = 0.6f), modifier = Modifier.size(24.dp)) + if (isActive) { + CircularProgressIndicator(progress = { (taskProgress / 100f).coerceIn(0f, 1f) }, modifier = Modifier.fillMaxSize(), color = PurrfectPalette.glowPrimary, strokeWidth = 3.dp, trackColor = Color.Transparent, strokeCap = StrokeCap.Round) } } - Column( - modifier = Modifier.weight(1f), - ) { - if (task.type == TaskType.SCHEDULED_SEND) { - // Professional design for scheduled send tasks - Column( - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - // Feature title - Text( - context.translation.getOrNull("scheduled_send_title") ?: "Scheduled Snaps", - style = MaterialTheme.typography.labelMedium, - color = PurrfectPalette.textSecondary, - fontWeight = androidx.compose.ui.text.font.FontWeight.Medium - ) - - // Scheduled time without icon - Text( - task.title, - style = MaterialTheme.typography.titleMedium, - fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, - color = Color.White - ) - - // Recipients with icon - task.author?.takeIf { it != "null" }?.let { recipients -> - Row( - verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - Icon( - Icons.Filled.People, - contentDescription = null, - modifier = Modifier.size(16.dp).padding(top = 2.dp), - tint = PurrfectPalette.textSecondary - ) - Text( - recipients, - style = MaterialTheme.typography.bodyMedium, - color = Color.White, - lineHeight = 20.sp - ) - } - } - } - } else { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Text(task.title, style = MaterialTheme.typography.bodyMedium, color = Color.White) - task.author?.takeIf { it != "null" }?.let { - Spacer(modifier = Modifier.width(5.dp)) - Text(it, style = MaterialTheme.typography.bodySmall, color = PurrfectPalette.textSecondary) - } - } - Text(task.hash, style = MaterialTheme.typography.labelSmall, color = PurrfectPalette.textSecondary) - } - Column( - modifier = Modifier.padding(top = 5.dp), - verticalArrangement = Arrangement.spacedBy(5.dp) - ) { - chipLabel?.let { label -> - val leadingIcon: (@Composable () -> Unit)? = if (isActive && task.type == TaskType.SCHEDULED_SEND) { - { - Icon( - Icons.Filled.Timer, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - } - } else if (isActive) { - countdownText?.let { countdown -> - { - Text( - countdown, - style = MaterialTheme.typography.labelSmall - ) - } - } - } else chipIcon?.let { icon -> - { Icon(icon, contentDescription = null) } - } - val displayLabel = if (isActive && task.type == TaskType.SCHEDULED_SEND && countdownText != null) { - val sendingText = translation.getOrNull("schedule_sending_in")?.replace("{time}", countdownText) ?: "Sending in $countdownText" - sendingText - } else { - label - } - AssistChip( - onClick = {}, - enabled = false, - leadingIcon = leadingIcon, - label = { Text(displayLabel) }, - colors = chipColors - ) - } - if (taskStatus.isFinalStage()) { - if (taskStatus != TaskStatus.SUCCESS) { - Text("$taskStatus", style = MaterialTheme.typography.bodySmall, color = PurrfectPalette.textSecondary) - } - } else { - if (!isActive) { - taskProgressLabel?.let { - Text(it, style = MaterialTheme.typography.bodySmall, color = Color.White) - } - } - if (taskProgress != -1 && taskProgressLabel == null) { - LinearProgressIndicator( - progress = { taskProgress.toFloat() / 100f }, - strokeCap = StrokeCap.Round, - modifier = Modifier.fillMaxWidth(), - color = PurrfectPalette.glowSecondary, - trackColor = Color.White.copy(alpha = 0.12f) - ) - } - if (!isActive) { - task.extra?.takeIf { it?.isNotEmpty() == true }?.let { - Text(it, style = MaterialTheme.typography.bodySmall, color = PurrfectPalette.textSecondary) - } - } - } - } + Column(modifier = Modifier.weight(1f)) { + Text(text = task.title, fontSize = 15.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(text = taskProgressLabel ?: task.author ?: "", fontSize = 12.sp, color = PurrfectPalette.textSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis) } - - Column { - if (pendingTask != null && !taskStatus.isFinalStage()) { - FilledIconButton( - onClick = { - runCatching { - pendingTask.cancel() - }.onFailure { throwable -> - context.log.error("Failed to cancel task $pendingTask", throwable) - } - }, - colors = IconButtonDefaults.filledIconButtonColors( - containerColor = Color(0xFFFF6B9B).copy(alpha = 0.35f), - contentColor = Color.White - ) - ) { - Icon(Icons.Filled.Close, contentDescription = "Cancel") - } - } else { - if (taskStatus == TaskStatus.SUCCESS) { - AnimatedVisibility( - visible = true, - enter = fadeIn(animationSpec = tween(250, easing = FastOutSlowInEasing)) + scaleIn(animationSpec = tween(300, easing = FastOutSlowInEasing)), - exit = fadeOut(animationSpec = tween(150)) + scaleOut(targetScale = 0.5f, animationSpec = tween(150)) - ) { - Box( - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - .background(PurrfectPalette.glowPrimary.copy(alpha = 0.22f)), - contentAlignment = Alignment.Center - ) { - Icon(Icons.Filled.Check, contentDescription = "Success", tint = Color.White) - } - } - } else { - when (taskStatus) { - TaskStatus.FAILURE -> Icon(Icons.Filled.Error, contentDescription = "Failure", tint = Color(0xFFFF6B9B)) - TaskStatus.CANCELLED -> Icon(Icons.Filled.Cancel, contentDescription = "Cancelled", tint = Color(0xFFFF6B9B)) - else -> {} - } - } - } + if (chipLabel != null || chipIcon != null) { + AssistChip(onClick = {}, label = { chipLabel?.let { Text(it) } }, leadingIcon = { chipIcon?.let { Icon(it, null, modifier = Modifier.size(18.dp)) } }, colors = AssistChipDefaults.assistChipColors(labelColor = Color.White, leadingIconContentColor = Color.White), border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)), shape = CircleShape) } } } } - override val content: @Composable (NavBackStackEntry) -> Unit = { - val scrollState = rememberLazyListState() - val scope = rememberCoroutineScope() - recentTasks = remember { mutableStateListOf() } - var lastFetchedTaskId by remember { mutableStateOf(null as Long?) } + override val init: () -> Unit = { + recentTasks = mutableStateListOf() + } + + override val content: @Composable (NavBackStackEntry) -> Unit = { nav -> + val themeId by produceState(initialValue = context.config.root.global.uiSettings.managerTheme.get()) { + while (true) { delay(300); value = context.config.root.global.uiSettings.managerTheme.get() } + } + key(themeId) { with(ManagerTheme.fromId(themeId).theme) { this@TasksRootSection.TasksScreen(nav) } } + } + + override val topBarActions: @Composable RowScope.() -> Unit = { var showConfirmDialog by remember { mutableStateOf(false) } - var alsoDeleteFiles by remember { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() - fun fetchNewRecentTasks() { - scope.launch(Dispatchers.IO) { - val tasks = context.taskManager.fetchStoredTasks(lastFetchedTaskId ?: Long.MAX_VALUE, limit = 20) - if (tasks.isNotEmpty()) { - lastFetchedTaskId = tasks.keys.last() - val activeTaskIds = activeTasks.map { it.taskId } - recentTasks.addAll(tasks.filter { it.key !in activeTaskIds }.values) + if (taskSelection.isNotEmpty()) { + val hapticFeedback = LocalHapticFeedback.current + 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 = { if (context.config.root.global.uiSettings.hapticFeedback.get()) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); mergeSelection(taskSelection.toList().also { taskSelection.clear() }.map { it.first to it.second!! }) }, icon = Icons.Filled.Merge, text = translation["merge_button"]) } } - } - - LaunchedEffect(Unit) { - fetchActiveTasks(this) - } - - DisposableEffect(Unit) { - onDispose { - taskSelection.clear() - } - } - - OnLifecycleEvent { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - fetchActiveTasks(scope) - } - } - - Box( - modifier = Modifier - .fillMaxSize() - .background(PurrfectPalette.backgroundGradient) - ) { - Column(modifier = Modifier.fillMaxSize()) { - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 12.dp) - .padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()), - shape = RoundedCornerShape(26.dp), - color = Color.White.copy(alpha = 0.07f), - tonalElevation = 0.dp, - shadowElevation = 0.dp, - border = BorderStroke( - 1.dp, - Brush.linearGradient( - listOf( - PurrfectPalette.glowPrimary.copy(alpha = 0.55f), - PurrfectPalette.glowSecondary.copy(alpha = 0.35f) - ) - ) - ) - ) { - 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 = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon(Icons.Filled.PlaylistAddCheckCircle, contentDescription = null, tint = Color.White) - Text( - text = translation.format("running_count", "count" to activeTasks.size.toString()), - color = Color.White, - fontWeight = FontWeight.SemiBold, - fontSize = 12.sp - ) - } - } - IconButton(onClick = { showConfirmDialog = true }) { - Icon(Icons.Filled.Delete, contentDescription = translation["clear_button_description"], tint = Color.White) - } - } - } - } - - Surface( - modifier = Modifier - .weight(1f) - .fillMaxWidth() - .padding(horizontal = 12.dp), - shape = RoundedCornerShape(22.dp), - color = Color.White.copy(alpha = 0.04f), - tonalElevation = 0.dp, - shadowElevation = 0.dp, - border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)) - ) { - 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() - } - } - } - } + IconButton(onClick = { if (context.config.root.global.uiSettings.hapticFeedback.get()) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); 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()) @@ -1046,14 +427,14 @@ class TasksRootSection : Routes.Route() { TaskDangerDialog( visible = showConfirmDialog, - title = titleText, - message = messageText, + title = titleText ?: "", + message = messageText ?: "", showDeleteFiles = isSelection, deleteFilesChecked = alsoDeleteFiles, onToggleDeleteFiles = { alsoDeleteFiles = it }, onConfirm = { showConfirmDialog = false - clearTasks(alsoDeleteFiles, scope) + clearTasks(alsoDeleteFiles, coroutineScope) }, onDismiss = { showConfirmDialog = false } ) 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 c54bb04d..482ff2e5 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,7 @@ package me.eternal.purrfectsnap.ui.manager.pages.features import android.net.Uri -import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.* import androidx.compose.animation.core.tween import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.LocalIndication @@ -9,28 +9,7 @@ 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.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.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.items @@ -91,6 +70,7 @@ import com.google.gson.reflect.TypeToken import me.eternal.purrfectsnap.common.ui.TopBarActionButton import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList import me.eternal.purrfectsnap.ui.manager.Routes +import me.eternal.purrfectsnap.ui.manager.ManagerTheme import me.eternal.purrfectsnap.ui.manager.rememberRouteLazyListState import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette import me.eternal.purrfectsnap.ui.util.* @@ -113,7 +93,7 @@ class FeaturesRootSection : Routes.Route() { } ?: routeInfo.translatedKey?.value } SEARCH_FEATURE_ROUTE -> { - translation["search_button"] + translation["search_button"] ?: "Search" } else -> { routeInfo.translatedKey?.value @@ -122,16 +102,16 @@ class FeaturesRootSection : Routes.Route() { Text(titleText ?: "", maxLines = 1, overflow = TextOverflow.Ellipsis) } - private val alertDialogs by lazy { AlertDialogs(context.translation) } - private val gson by lazy { Gson() } - private val listTypeToken = object : TypeToken>() {}.type + internal val alertDialogs by lazy { AlertDialogs(context.translation) } + internal val gson by lazy { Gson() } + internal val listTypeToken = object : TypeToken>() {}.type companion object { const val FEATURE_CONTAINER_ROUTE = "feature_container/{name}" const val SEARCH_FEATURE_ROUTE = "search_feature/{keyword}" } - private val allContainers by lazy { + internal val allContainers by lazy { val containers = mutableMapOf>() fun queryContainerRecursive(container: ConfigContainer) { container.properties.forEach { @@ -139,7 +119,7 @@ class FeaturesRootSection : Routes.Route() { it.key.dataType.type == DataProcessors.Type.CONTAINER && !it.key.params.flags.contains(ConfigFlag.HIDDEN) ) { - containers[it.key.name] = PropertyPair(it.key, it.value) + containers[it.key.name] = PropertyPair(it.key as PropertyKey, it.value as PropertyValue) queryContainerRecursive(it.value.get() as ConfigContainer) } } @@ -148,7 +128,7 @@ class FeaturesRootSection : Routes.Route() { containers } - private val allProperties by lazy { + internal val allProperties by lazy { val properties = mutableMapOf, PropertyValue<*>>() allContainers.values.forEach { val container = it.value.get() as ConfigContainer @@ -159,13 +139,13 @@ class FeaturesRootSection : Routes.Route() { properties } - private fun isSearchVisibleProperty(propertyKey: PropertyKey<*>): Boolean { + internal fun isSearchVisibleProperty(propertyKey: PropertyKey<*>): Boolean { return !propertyKey.params.flags.contains(ConfigFlag.HIDDEN) } - private data class SearchEntry(val keyword: String, val tokens: List) + internal data class SearchEntry(val keyword: String, val tokens: List) - private fun buildSearchEntries(): List { + internal fun buildSearchEntries(): List { return allProperties.keys.mapNotNull { key -> if (!isSearchVisibleProperty(key)) return@mapNotNull null val name = context.translation[key.propertyName()] @@ -175,7 +155,7 @@ class FeaturesRootSection : Routes.Route() { } } - private fun levenshtein(a: String, b: String): Int { + internal fun levenshtein(a: String, b: String): Int { if (a == b) return 0 if (a.isEmpty()) return b.length if (b.isEmpty()) return a.length @@ -195,7 +175,7 @@ class FeaturesRootSection : Routes.Route() { return curr[b.length] } - private fun similarityScore(query: String, target: String): Float { + internal fun similarityScore(query: String, target: String): Float { val q = query.lowercase() val t = target.lowercase() val maxLen = max(q.length, t.length) @@ -204,7 +184,7 @@ class FeaturesRootSection : Routes.Route() { return 1f - (dist.toFloat() / maxLen.toFloat()) } - private fun fuzzySuggest(query: String, entries: List): List { + internal fun fuzzySuggest(query: String, entries: List): List { val q = query.trim() if (q.length < 2) return emptyList() return entries.map { entry -> @@ -217,22 +197,22 @@ class FeaturesRootSection : Routes.Route() { .take(6) } - private fun loadSearchHistory(): List { + internal fun loadSearchHistory(): List { return context.sharedPreferences - .getString("features_search_history", "") // stored as | separated + .getString("features_search_history", "") ?.split("|") ?.map { it.trim() } ?.filter { it.isNotEmpty() } ?: emptyList() } - private fun saveSearchHistory(history: List) { + internal fun saveSearchHistory(history: List) { context.sharedPreferences.edit() .putString("features_search_history", history.joinToString("|")) .apply() } - private fun upsertHistory(term: String, history: SnapshotStateList) { + internal fun upsertHistory(term: String, history: SnapshotStateList) { val cleaned = term.trim() if (cleaned.isEmpty()) return val existingIndex = history.indexOfFirst { it.equals(cleaned, ignoreCase = true) } @@ -242,7 +222,7 @@ class FeaturesRootSection : Routes.Route() { saveSearchHistory(history) } - private fun navigateToMainRoot() { + internal fun navigateToMainRoot() { routes.navController.navigate(routeInfo.id, NavOptions.Builder() .setPopUpTo(routes.navController.graph.findStartDestination().id, false) .setLaunchSingleTop(true) @@ -250,15 +230,23 @@ class FeaturesRootSection : Routes.Route() { ) } - private fun activityLauncher(block: ActivityLauncherHelper.() -> Unit) { + internal fun activityLauncher(block: ActivityLauncherHelper.() -> Unit) { routes.activityLauncher.let(block) } - override val content: @Composable (NavBackStackEntry) -> Unit = { - Container( - configContainer = context.config.root, - stateKey = "${routeInfo.id}:root" - ) + override val content: @Composable (NavBackStackEntry) -> Unit = { nav -> + val themeId by produceState(initialValue = context.config.root.global.uiSettings.managerTheme.get()) { + while (true) { + delay(300) + value = context.config.root.global.uiSettings.managerTheme.get() + } + } + + key(themeId) { + with(ManagerTheme.fromId(themeId).theme) { + this@FeaturesRootSection.FeaturesScreen(nav) + } + } } override val customComposables: NavGraphBuilder.() -> Unit = { @@ -298,7 +286,7 @@ class FeaturesRootSection : Routes.Route() { context.translation[it.key.propertyName()].contains(keyword, ignoreCase = true) || context.translation[it.key.propertyDescription()].contains(keyword, ignoreCase = true) ) - }.map { PropertyPair(it.key, it.value) } + }.map { PropertyPair(it.key as PropertyKey, it.value as PropertyValue) } PropertiesView( properties = properties, @@ -313,7 +301,7 @@ class FeaturesRootSection : Routes.Route() { } @Composable - private fun FeatureAuroraBackdrop(modifier: Modifier = Modifier) { + internal fun FeatureAuroraBackdrop(modifier: Modifier = Modifier) { Box( modifier = modifier .fillMaxSize() @@ -322,7 +310,7 @@ class FeaturesRootSection : Routes.Route() { } @Composable - private fun NoticeBadge(text: String, color: Color) { + internal fun NoticeBadge(text: String, color: Color) { Surface( shape = RoundedCornerShape(50), color = color.copy(alpha = 0.16f), @@ -341,7 +329,7 @@ class FeaturesRootSection : Routes.Route() { } @Composable - private fun EmptyState(isSearchResults: Boolean) { + internal fun EmptyState(isSearchResults: Boolean) { val shape = RoundedCornerShape(24.dp) val border = Brush.linearGradient( listOf( @@ -399,7 +387,7 @@ class FeaturesRootSection : Routes.Route() { } @Composable - private fun PropertyAction(property: PropertyPair<*>, registerClickCallback: RegisterClickCallback) { + internal fun PropertyAction(property: PropertyPair<*>, registerClickCallback: RegisterClickCallback) { var showDialog by remember { mutableStateOf(false) } var dialogComposable by remember { mutableStateOf<@Composable () -> Unit>({}) } @@ -473,14 +461,14 @@ class FeaturesRootSection : Routes.Route() { verticalArrangement = Arrangement.spacedBy(6.dp) ) { Text( - text = context.translation["manager.dialogs.file_imports.settings_select_file_hint"], + text = context.translation["manager.dialogs.file_imports.settings_select_file_hint"] ?: "Select File", fontSize = 18.sp, fontWeight = FontWeight.ExtraBold, color = Color.White ) if (isEmpty) { Text( - text = context.translation["manager.dialogs.file_imports.no_files_settings_hint"], + text = context.translation["manager.dialogs.file_imports.no_files_settings_hint"] ?: "No files found", fontSize = 15.sp, fontWeight = FontWeight.Medium, color = PurrfectPalette.textSecondary, @@ -489,8 +477,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"], + text = translation["manager.dialogs.file_imports.settings_select_file_subtitle"] ?: "Pick a file to import", fontSize = 13.sp, color = PurrfectPalette.textSecondary, textAlign = TextAlign.Center @@ -645,7 +632,6 @@ class FeaturesRootSection : Routes.Route() { alertDialogs.MultipleSelectionDialog(property) } DataProcessors.Type.STRING, DataProcessors.Type.INTEGER, DataProcessors.Type.FLOAT -> { - // Check if this is a message list property val isMessageListProperty = property.key.name.endsWith("_messages") if (isMessageListProperty) { alertDialogs.MessageListPropertyDialog(property) { showDialog = false } @@ -665,10 +651,8 @@ class FeaturesRootSection : Routes.Route() { onClick = it ) } else { - // Check if this is a message list property val isMessageListProperty = property.key.name.endsWith("_messages") if (isMessageListProperty) { - // Show message count val messageCount = try { val messageList: List = gson.fromJson(propertyValue.get().toString(), listTypeToken) ?: emptyList() messageList.size @@ -755,7 +739,7 @@ class FeaturesRootSection : Routes.Route() { } @Composable - private fun ValueGlowChip( + internal fun ValueGlowChip( text: String, onClick: () -> Unit ) { @@ -801,7 +785,7 @@ class FeaturesRootSection : Routes.Route() { } @Composable - private fun PropertyCard(property: PropertyPair<*>, onOpen: (() -> Unit)? = null) { + internal fun PropertyCard(property: PropertyPair<*>, onOpen: (() -> Unit)? = null) { var clickCallback by remember { mutableStateOf(null) } val noticeColorMap = remember { mapOf( @@ -817,7 +801,6 @@ class FeaturesRootSection : Routes.Route() { val cardShape = RoundedCornerShape(22.dp) val interactionSource = remember { MutableInteractionSource() } - val density = LocalDensity.current val cardBorder = remember { Brush.linearGradient( listOf( @@ -892,14 +875,14 @@ class FeaturesRootSection : Routes.Route() { verticalArrangement = Arrangement.spacedBy(6.dp) ) { Text( - text = context.translation[property.key.propertyName()], + text = context.translation[property.key.propertyName()] ?: property.key.name, 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 @@ -909,7 +892,7 @@ class FeaturesRootSection : Routes.Route() { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { property.key.params.notices.forEach { NoticeBadge( - text = context.translation["features.notices.${it.key}"], + text = context.translation["features.notices.${it.key}"] ?: it.key, color = noticeColorMap[it.key] ?: Color(0xFFFFFB87) ) } @@ -949,241 +932,18 @@ 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( + internal fun FloatingControls( isSearchResults: Boolean, - modifier: Modifier = Modifier, activeSectionTitle: String? = null, activeSectionSubtitle: String? = null, searchKeyword: String? = null, searchHistory: SnapshotStateList, onSearchQueryChange: (String) -> Unit, - onBack: (() -> Unit)? = null + onBack: (() -> Unit)? = null, + scrollOffset: Int = 0, + modifier: Modifier = Modifier ) { var showSearchBar by rememberSaveable { mutableStateOf(isSearchResults) } val focusRequester = remember { FocusRequester() } @@ -1219,6 +979,7 @@ 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), @@ -1241,12 +1002,12 @@ class FeaturesRootSection : Routes.Route() { verticalArrangement = Arrangement.spacedBy(14.dp) ) { Text( - text = context.translation["manager.dialogs.reset_config.title"], + text = context.translation["manager.dialogs.reset_config.title"] ?: "Reset Config", style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.ExtraBold), color = Color.White ) Text( - text = context.translation["manager.dialogs.reset_config.content"], + text = context.translation["manager.dialogs.reset_config.content"] ?: "Reset all settings to default?", style = MaterialTheme.typography.bodyMedium, color = PurrfectPalette.textSecondary ) @@ -1255,7 +1016,10 @@ class FeaturesRootSection : Routes.Route() { horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End) ) { Button( - onClick = { showResetConfirmationDialog = false }, + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + showResetConfirmationDialog = false + }, colors = ButtonDefaults.buttonColors( containerColor = Color.White.copy(alpha = 0.08f), contentColor = Color.White @@ -1265,8 +1029,9 @@ class FeaturesRootSection : Routes.Route() { } Button( onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) context.config.reset() - context.shortToast(context.translation["manager.dialogs.reset_config.success_toast"]) + context.shortToast(context.translation["manager.dialogs.reset_config.success_toast"] ?: "Reset successful") showResetConfirmationDialog = false }, colors = ButtonDefaults.buttonColors( @@ -1296,37 +1061,40 @@ class FeaturesRootSection : Routes.Route() { ) } + val haptic = LocalHapticFeedback.current val actions = remember { listOf( - Triple(translation["export_option"], Icons.Filled.SaveAlt) { showExportDialog = true }, - Triple(translation["import_option"], Icons.Filled.FileDownload) { + Triple(translation["export_option"] ?: "Export", Icons.Filled.SaveAlt) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + showExportDialog = true + }, + Triple(translation["import_option"] ?: "Import", Icons.Filled.FileDownload) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) 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) + 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"], Icons.Filled.Refresh) { showResetConfirmationDialog = true } + Triple(translation["reset_option"] ?: "Reset", Icons.Filled.Refresh) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + showResetConfirmationDialog = true + } ) } - 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 headerTitle = activeSectionTitle ?: translation["manager.routes.features"] ?: "Features" val subtitleText = when { - isSearchResults -> translation["search_button"] + isSearchResults -> translation["search_button"] ?: "Search" !activeSectionSubtitle.isNullOrBlank() -> activeSectionSubtitle else -> translation["manager.sections.features.subtitle"] ?: "" } @@ -1344,223 +1112,182 @@ class FeaturesRootSection : Routes.Route() { } } - 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 - ) + Column(modifier = modifier) { + me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar( + title = headerTitle, + subtitle = if (showSearchBar) null else subtitleText, + onBack = onBack, + scrollOffset = scrollOffset, + actions = { + if (showSearchBar) { + TextField( + value = searchValue, + onValueChange = { keywordValue -> + searchValue = keywordValue + if (keywordValue.text.isEmpty()) { + updateSearch("", record = false) + } else { + updateSearch(keywordValue.text, record = false) } - } - - if (showSearchBar) { - TextField( - value = searchValue, - onValueChange = { keywordValue -> - searchValue = keywordValue - if (keywordValue.text.isEmpty()) { - updateSearch("", record = false) - } else { - 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) - ) + }, + 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 ) - 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) { + }, + trailingIcon = { + if (searchValue.text.isNotEmpty()) { + IconButton(onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) searchValue = TextFieldValue("", TextRange(0)) - if (isOverlay) { - routes.navController.popBackStack(routeInfo.id, false) + 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) } - showSearchBar = !showSearchBar - }) { - Icon( - imageVector = if (showSearchBar) Icons.Filled.Close else Icons.Filled.Search, - 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 = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + 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) }, - onClick = { - action() - showExportDropdownMenu = false - }, - colors = MenuDefaults.itemColors( - textColor = Color.White, - leadingIconColor = PurrfectPalette.glowPrimary - ) + if (context.activity != null) { + Box { + IconButton(onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + 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 + ) + ) } } } + } + } + ) - 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 = { - 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 (showSearchBar && combinedSuggestions.isNotEmpty()) { + Surface( + modifier = Modifier + .padding(horizontal = 14.dp) + .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 = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + 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()) - } - ) { - 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) + } + if (searchHistory.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + 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"] ?: "Clear history", color = Color.White) } } } @@ -1568,11 +1295,10 @@ class FeaturesRootSection : Routes.Route() { } } } - } @Composable - private fun PropertiesView( + internal fun PropertiesView( properties: List>, stateKey: String, isSearchResults: Boolean = false, @@ -1583,14 +1309,14 @@ class FeaturesRootSection : Routes.Route() { onBack: (() -> Unit)? = null ) { val density = LocalDensity.current - var controlsHeight by remember { mutableStateOf(96.dp) } + var controlsHeight by remember { mutableStateOf(100.dp) } val listState = rememberRouteLazyListState(stateKey) 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.filter { isSearchVisibleProperty(it.key) }.map { PropertyPair(it.key, it.value) } + allProperties.filter { isSearchVisibleProperty(it.key) }.map { PropertyPair(it.key as PropertyKey, it.value as PropertyValue) } } else { emptyList() } @@ -1611,23 +1337,38 @@ class FeaturesRootSection : Routes.Route() { liveSearchQuery = searchKeyword } } + var lastAppliedQuery by remember { mutableStateOf(liveSearchQuery) } LaunchedEffect(liveSearchQuery) { - if (!listState.isScrollInProgress) { - listState.scrollToItem(0) + if (liveSearchQuery != lastAppliedQuery) { + if (!listState.isScrollInProgress) { + listState.scrollToItem(0) + } + lastAppliedQuery = liveSearchQuery } } + val computedScrollOffset by remember { + derivedStateOf { + if (listState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() + else listState.firstVisibleItemScrollOffset + } + } + + LaunchedEffect(computedScrollOffset) { + routes.navigation?.globalScrollOffset = computedScrollOffset + } + 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 + 12.dp, + top = controlsHeight, bottom = routes.bottomPadding ) ) { @@ -1645,6 +1386,7 @@ class FeaturesRootSection : Routes.Route() { } item { Spacer(modifier = Modifier.height(12.dp)) } } + FloatingControls( isSearchResults = isActiveSearch, activeSectionTitle = activeSectionTitle, @@ -1653,12 +1395,8 @@ class FeaturesRootSection : Routes.Route() { searchHistory = sharedSearchHistory, onSearchQueryChange = { liveSearchQuery = it }, onBack = onBack, - modifier = Modifier.onGloballyPositioned { - val newHeight = with(density) { it.size.height.toDp() } - if (newHeight != controlsHeight) { - controlsHeight = newHeight - } - } + scrollOffset = computedScrollOffset, + modifier = Modifier.headerHeightTracker { controlsHeight = it } ) } } @@ -1684,10 +1422,112 @@ class FeaturesRootSection : Routes.Route() { } } + @Composable + internal fun SensitiveDataDialog( + onDismiss: () -> Unit, + onConfirm: (exportSensitiveData: Boolean, includeSavedLocations: Boolean) -> Unit + ) { + val haptic = LocalHapticFeedback.current + 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 = context.translation["include_saved_locations"] ?: "Include Saved Locations", + style = MaterialTheme.typography.bodyMedium, + color = Color.White + ) + Switch( + checked = includeSavedLocations.value, + onCheckedChange = { + if (context.config.root.global.uiSettings.hapticFeedback.get()) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + } + includeSavedLocations.value = it + }, + colors = purrfectSwitchColors() + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End) + ) { + Button( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onDismiss() + }, + 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( + internal fun Container( configContainer: ConfigContainer, stateKey: String, sectionTitle: String? = null, @@ -1697,7 +1537,7 @@ class FeaturesRootSection : Routes.Route() { ) { PropertiesView( properties = remember { - configContainer.properties.map { PropertyPair(it.key, it.value) }.filter { + configContainer.properties.map { PropertyPair(it.key as PropertyKey, it.value as PropertyValue) }.filter { !it.key.params.flags.contains(ConfigFlag.HIDDEN) } }, 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 d1a7949a..c7a3e22b 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,343 +1,126 @@ 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.rememberScrollState +import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.Icon -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableLongStateOf -import androidx.compose.runtime.remember +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -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 -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavBackStackEntry +import kotlinx.coroutines.delay import me.eternal.purrfectsnap.R -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.ManagerTheme import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette -import android.os.SystemClock +import me.eternal.purrfectsnap.ui.util.PurrfectMarqueeText +import me.eternal.purrfectsnap.ui.util.scaleOnPress class HomeAbout : Routes.Route() { - override val content: @Composable (NavBackStackEntry) -> Unit = { - val avenirNext = remember { - FontFamily(Font(R.font.avenir_next_medium, FontWeight.Medium)) + override val translation by lazy { context.translation.getCategory("manager.navigation.home_about") } + + override val content: @Composable (NavBackStackEntry) -> Unit = { nav -> + val themeId by produceState( + initialValue = context.config.root.global.uiSettings.managerTheme.get() + ) { + while (true) { + delay(300) + value = context.config.root.global.uiSettings.managerTheme.get() + } } - val scrollState = rememberScrollState() - val aboutStory = remember { translation["about_story"] } - val pagePadding = 16.dp - 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) } LaunchedEffect(Unit) { context.shortToast(translation["about_magic_toast"]) } - Box( - modifier = Modifier - .fillMaxSize() - .background(PurrfectPalette.backgroundGradient) - ) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(scrollState) - .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) - .fillMaxWidth(), - shape = RoundedCornerShape(30.dp), - color = Color.Transparent, - border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)), - tonalElevation = 0.dp, - shadowElevation = 0.dp - ) { - Column( - modifier = Modifier - .background(PurrfectPalette.panelGradient) - .padding(horizontal = 22.dp, vertical = 20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = translation["about_title"], - fontSize = 28.sp, - fontWeight = FontWeight.ExtraBold, - color = PurrfectPalette.textPrimary, - fontFamily = avenirNext, - modifier = Modifier.clickable( - interactionSource = tapSource, - indication = null - ) { - val now = SystemClock.elapsedRealtime() - if (now - lastTapTime.longValue > tapTimeoutMs) { - tapCount.intValue = 0 - } - tapCount.intValue += 1 - lastTapTime.longValue = now - if (tapCount.intValue >= 5) { - tapCount.intValue = 0 - routes.retroGame.navigate() - } - } - ) - Text( - text = translation["about_tagline"], - fontSize = 13.sp, - color = PurrfectPalette.textSecondary, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() - ) - Text( - text = translation["about_lead_developers_title"], - fontSize = 15.sp, - fontWeight = FontWeight.SemiBold, - color = Color.White, - modifier = Modifier.padding(top = 10.dp) - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), - verticalAlignment = Alignment.CenterVertically - ) { - DeveloperCard( - name = "ΞTΞRNAL", - imageRes = R.drawable.pfp_external, - avenirNext = avenirNext, - modifier = Modifier.weight(1f) - ) - DeveloperCard( - name = "", - imageRes = R.drawable.pfp_rsr, - avenirNext = avenirNext, - modifier = Modifier.weight(1f) - ) - } - } - } - - Spacer(modifier = Modifier.height(14.dp)) - - Surface( - modifier = Modifier - .padding(horizontal = pagePadding) - .fillMaxWidth(), - shape = RoundedCornerShape(26.dp), - color = Color.Transparent, - border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)), - tonalElevation = 0.dp, - shadowElevation = 0.dp - ) { - Column( - modifier = Modifier - .background(PurrfectPalette.cardOverlay) - .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 - ) - Text( - text = aboutStory, - fontSize = 14.sp, - color = PurrfectPalette.textSecondary, - lineHeight = 20.sp - ) - } - } - - Spacer(modifier = Modifier.height(14.dp)) - - Surface( - modifier = Modifier - .padding(horizontal = pagePadding) - .fillMaxWidth(), - shape = RoundedCornerShape(24.dp), - color = Color.White.copy(alpha = 0.08f), - border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)), - tonalElevation = 0.dp, - shadowElevation = 0.dp - ) { - Column( - modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = translation["about_thanks_title"], - fontSize = 15.sp, - fontWeight = FontWeight.SemiBold, - color = Color.White, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Button( - modifier = Modifier.weight(1f), - onClick = { - context.androidContext.openLink( - "https://github.com/particle-box/PurrfectSnap", - context.translation["toast_open_link_failed"] - ) - }, - colors = ButtonDefaults.buttonColors( - containerColor = Color.White, - contentColor = Color(0xFF1B152E) - ) - ) { - Icon( - imageVector = ImageVector.vectorResource(id = R.drawable.ic_github), - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(text = translation["github_button"], maxLines = 1, overflow = TextOverflow.Ellipsis) - } - OutlinedButton( - modifier = Modifier.weight(1f), - onClick = { - context.androidContext.openLink( - "https://t.me/purrfectsnap_official", - context.translation["toast_open_link_failed"] - ) - }, - border = BorderStroke(1.dp, Color.White.copy(alpha = 0.35f)), - colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White) - ) { - Icon( - imageVector = ImageVector.vectorResource(id = R.drawable.ic_telegram), - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = Color.White - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(text = translation["telegram_button"], maxLines = 1, overflow = TextOverflow.Ellipsis) - } - } - } - } - - Spacer(modifier = Modifier.height(32.dp)) + key(themeId) { + val currentTheme = ManagerTheme.fromId(themeId).theme + with(currentTheme) { + this@HomeAbout.AboutScreen(nav) } } } @Composable - private fun DeveloperCard( + internal fun DeveloperCard( name: String, imageRes: Int, avenirNext: FontFamily, modifier: Modifier = Modifier ) { - val cardShape = RoundedCornerShape(20.dp) - val imageRing = Brush.linearGradient( - listOf( - PurrfectPalette.glowPrimary, - PurrfectPalette.glowSecondary - ) - ) + val tapSource = remember { MutableInteractionSource() } + val tapTimeoutMs = 1500L + val tapCount = remember { mutableIntStateOf(0) } + val lastTapTime = remember { mutableLongStateOf(0L) } Surface( - modifier = modifier, - shape = cardShape, - color = Color.White.copy(alpha = 0.08f), - border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)), + onClick = { + val now = SystemClock.elapsedRealtime() + if (now - lastTapTime.longValue > tapTimeoutMs) { + tapCount.intValue = 0 + } + tapCount.intValue += 1 + lastTapTime.longValue = now + if (tapCount.intValue >= 5) { + tapCount.intValue = 0 + routes.retroGame.navigate() + } + }, + modifier = modifier.scaleOnPress(tapSource), + interactionSource = tapSource, + shape = RoundedCornerShape(22.dp), + color = Color.White.copy(alpha = 0.06f), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)), tonalElevation = 0.dp, shadowElevation = 0.dp ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 16.dp), + modifier = Modifier.padding(14.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp) ) { - Box( - modifier = Modifier - .size(82.dp) - .clip(CircleShape) - .background(Color.White.copy(alpha = 0.1f)) - .border(2.dp, imageRing, CircleShape) + Surface( + modifier = Modifier.size(64.dp), + shape = CircleShape, + color = Color.Transparent, + border = BorderStroke(2.dp, Brush.linearGradient(listOf(PurrfectPalette.glowPrimary, PurrfectPalette.glowSecondary))) ) { Image( painter = painterResource(id = imageRes), contentDescription = name, contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize().clip(CircleShape) ) } - Text( + PurrfectMarqueeText( text = name, - fontSize = 16.sp, - fontWeight = FontWeight.Bold, color = Color.White, - fontFamily = avenirNext, - maxLines = 1, - overflow = TextOverflow.Ellipsis + style = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + fontFamily = avenirNext + ) ) -} + } } } } 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 d82d5ce4..d9011557 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,6 +64,7 @@ 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.ManagerTheme import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper import me.eternal.purrfectsnap.ui.util.pullrefresh.PullRefreshIndicator @@ -73,14 +74,14 @@ import me.eternal.purrfectsnap.ui.util.saveFile import me.eternal.purrfectsnap.common.util.ktx.copyToClipboard class HomeLogs : Routes.Route() { - private val logListState by lazy { LazyListState(0) } - private lateinit var activityLauncherHelper: ActivityLauncherHelper - private val externalRefreshTick = mutableStateOf(0) + internal val logListState = LazyListState() + internal lateinit var activityLauncherHelper: ActivityLauncherHelper + internal val externalRefreshTick = mutableIntStateOf(0) override val init: () -> Unit = { activityLauncherHelper = ActivityLauncherHelper(context.activity!!) } - private fun clearLogsAndReload() { + internal fun clearLogsAndReload() { context.coroutineScope.launch { context.log.clearLogs() withContext(Dispatchers.Main) { @@ -89,7 +90,7 @@ class HomeLogs : Routes.Route() { } } - private fun exportLogs() { + internal fun exportLogs() { activityLauncherHelper.saveFile("purrfectsnap-logs-${System.currentTimeMillis()}.zip", "application/zip") { uri -> context.coroutineScope.launch { context.shortToast(translation["saving_logs_toast"]) @@ -107,118 +108,13 @@ class HomeLogs : Routes.Route() { } override val topBarActions: @Composable (RowScope.() -> Unit) = {} - override val content: @Composable (NavBackStackEntry) -> Unit = { - val coroutineScope = rememberCoroutineScope() - 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 - mainExecutor.execute { - visibleLogs.add(line) - } - } - } - } - readerResult.onFailure { - context.longToast(translation["read_logs_failed_toast"]) - } - readerResult.getOrNull()?.let { reader -> - logReader = reader - val filteredLogs = withContext(Dispatchers.IO) { - (0 until reader.lineCount).mapNotNull { index -> - reader.getLogLine(index)?.takeUnless(::shouldHideLog) - } - } - visibleLogs.clear() - visibleLogs.addAll(filteredLogs) - } - delay(220) - if (visibleLogs.isNotEmpty()) { - val targetIndex = (visibleLogs.size - 1).coerceAtLeast(0) - logListState.scrollToItem(targetIndex) - } - isRefreshing = false - } - } - LaunchedEffect(externalRefreshTick.value) { - if (externalRefreshTick.value > 0) { - isRefreshing = true - refreshLogs() - } - } - val pullRefreshState = rememberPullRefreshState(isRefreshing, onRefresh = { - isRefreshing = true - refreshLogs() - }) - LaunchedEffect(Unit) { - isRefreshing = true - refreshLogs() - } - Box( - modifier = Modifier - .fillMaxSize() - .background(PurrfectPalette.backgroundGradient) - .pullRefresh(pullRefreshState) - ) { - Column(modifier = Modifier.fillMaxSize()) { - LogsFloatingBar( - isRefreshing = isRefreshing, - onRefresh = { - isRefreshing = true - refreshLogs() - }, - onExport = { exportLogs() }, - onClear = { clearLogsAndReload() } - ) - Spacer(modifier = Modifier.height(12.dp)) - Surface( - modifier = Modifier - .weight(1f) - .padding(horizontal = 12.dp), - shape = RoundedCornerShape(24.dp), - color = Color.White.copy(alpha = 0.04f), - tonalElevation = 0.dp, - shadowElevation = 0.dp, - border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)) - ) { - if (visibleLogs.isEmpty() && logReader != null) { - EmptyLogsState() - } else { - LazyColumn( - modifier = Modifier - .fillMaxSize(), - state = logListState, - verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues( - start = 8.dp, - end = 8.dp, - top = 12.dp, - bottom = routes.bottomPadding + 22.dp - ) - ) { - items(visibleLogs, key = { it.hashCode() }) { line -> - LogEntryCard(line = line, composeContext = composeContext) - } - } - } - } - } - PullRefreshIndicator( - refreshing = isRefreshing, - state = pullRefreshState, - modifier = Modifier - .align(Alignment.TopCenter) - .padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 8.dp) - ) + override val content: @Composable (NavBackStackEntry) -> Unit = { nav -> + val themeId by produceState(initialValue = context.config.root.global.uiSettings.managerTheme.get()) { + while (true) { delay(300); value = context.config.root.global.uiSettings.managerTheme.get() } } + key(themeId) { with(ManagerTheme.fromId(themeId).theme) { this@HomeLogs.LogsScreen(nav) } } } + override val floatingActionButton: @Composable () -> Unit = { val coroutineScope = rememberCoroutineScope() Column( @@ -271,20 +167,19 @@ class HomeLogs : Routes.Route() { } @Composable - private fun LogsFloatingBar( + internal fun LogsFloatingBar( isRefreshing: Boolean, onRefresh: () -> Unit, onExport: () -> Unit, onClear: () -> Unit ) { - var showDropDown by remember { mutableStateOf(false) } - val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + var showMenu by remember { mutableStateOf(false) } Surface( modifier = Modifier .fillMaxWidth() .padding(horizontal = 14.dp, vertical = 12.dp) - .padding(top = topPadding), - shape = RoundedCornerShape(28.dp), + .padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()), + shape = RoundedCornerShape(26.dp), color = Color.White.copy(alpha = 0.07f), tonalElevation = 0.dp, shadowElevation = 0.dp, @@ -292,87 +187,93 @@ class HomeLogs : Routes.Route() { 1.dp, Brush.linearGradient( listOf( - PurrfectPalette.glowPrimary.copy(alpha = 0.6f), - PurrfectPalette.glowSecondary.copy(alpha = 0.45f) + PurrfectPalette.glowPrimary.copy(alpha = 0.55f), + PurrfectPalette.glowSecondary.copy(alpha = 0.35f) ) ) ) ) { - Column( - modifier = Modifier.padding(horizontal = 18.dp, vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween ) { Row( - modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - 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 + IconButton(onClick = { routes.navController.popBackStack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = translation["common.back"], + tint = Color.White ) } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - if (isRefreshing) { - CircularProgressIndicator( - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp, - color = PurrfectPalette.glowSecondary + Text( + text = translation["manager.routes.home_logs"] ?: "Logs", + color = Color.White, + fontWeight = FontWeight.ExtraBold, + fontSize = 18.sp + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + IconButton(onClick = onRefresh, enabled = !isRefreshing) { + Icon( + imageVector = Icons.Filled.Refresh, + contentDescription = translation["refresh_button_description"], + tint = Color.White + ) + } + Box { + IconButton(onClick = { showMenu = true }) { + Icon( + imageVector = Icons.Filled.MoreVert, + contentDescription = null, + tint = Color.White ) } - 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 + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + offset = DpOffset(0.dp, 8.dp), + containerColor = Color(0xFF161821), + shape = RoundedCornerShape(14.dp), + tonalElevation = 8.dp, + shadowElevation = 12.dp + ) { + DropdownMenuItem( + leadingIcon = { + Icon( + imageVector = Icons.Filled.Download, + contentDescription = null, + tint = 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 + }, + text = { Text(text = translation["export_button"] ?: "Export", color = Color.White) }, + onClick = { + onExport() + showMenu = false + } + ) + DropdownMenuItem( + leadingIcon = { + Icon( + imageVector = Icons.Filled.DeleteSweep, + contentDescription = null, + tint = Color(0xFFFF9CAB) ) - ) - } + }, + text = { Text(text = translation["clear_button"] ?: "Clear", color = Color.White) }, + onClick = { + onClear() + showMenu = false + } + ) } } } @@ -381,7 +282,7 @@ class HomeLogs : Routes.Route() { } @Composable - private fun EmptyLogsState() { + internal fun EmptyLogsState() { Column( modifier = Modifier .fillMaxSize() @@ -422,8 +323,7 @@ class HomeLogs : Routes.Route() { } @Composable - private fun LogEntryCard(line: LogLine, composeContext: android.content.Context) { - // Normalize overly fragmented log text (some entries were rendered with one character per line) + internal fun LogEntryCard(line: LogLine, composeContext: android.content.Context) { val normalizedMessage = remember(line.message) { val cleaned = line.message.replace("\r", "") val fragments = cleaned.lines() @@ -534,14 +434,14 @@ class HomeLogs : Routes.Route() { } } - private fun logLevelColor(logLevel: LogLevel): Color = when (logLevel) { + internal fun logLevelColor(logLevel: LogLevel): Color = when (logLevel) { LogLevel.DEBUG -> PurrfectPalette.glowSecondary LogLevel.INFO, LogLevel.VERBOSE -> Color(0xFFA3F0C2) LogLevel.WARN -> Color(0xFFFFD782) LogLevel.ERROR, LogLevel.ASSERT -> Color(0xFFFF9CAB) } - private fun logLevelLabel(logLevel: LogLevel): String = when (logLevel) { + internal fun logLevelLabel(logLevel: LogLevel): String = when (logLevel) { LogLevel.DEBUG -> "Debug" LogLevel.INFO -> "Info" LogLevel.VERBOSE -> "Verbose" @@ -550,14 +450,14 @@ class HomeLogs : Routes.Route() { LogLevel.ASSERT -> "Assert" } - private fun logLevelIcon(logLevel: LogLevel) = when (logLevel) { + internal fun logLevelIcon(logLevel: LogLevel) = when (logLevel) { LogLevel.DEBUG -> Icons.Outlined.BugReport LogLevel.ERROR, LogLevel.ASSERT -> Icons.Outlined.Report LogLevel.INFO, LogLevel.VERBOSE -> Icons.Outlined.Info LogLevel.WARN -> Icons.Outlined.Warning } - private fun shouldHideLog(line: LogLine): Boolean { + internal fun shouldHideLog(line: LogLine): Boolean { val message = line.message.lowercase() val tag = line.tag.lowercase() return message.startsWith("blocked ep") || 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 8a84379c..c9526139 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 @@ -82,6 +82,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.key import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -92,6 +94,8 @@ import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.hapticfeedback.HapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.SpanStyle @@ -122,6 +126,7 @@ import me.eternal.purrfectsnap.common.util.ktx.openLink import me.eternal.purrfectsnap.storage.getQuickTiles import me.eternal.purrfectsnap.storage.setQuickTiles import me.eternal.purrfectsnap.ui.manager.Routes +import me.eternal.purrfectsnap.ui.manager.ManagerTheme import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette import me.eternal.purrfectsnap.ui.manager.data.UpdateDownloader import me.eternal.purrfectsnap.ui.manager.data.Updater @@ -137,7 +142,7 @@ 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" + internal const val QUICK_TILES_INITIALIZED_PREF = "quick_tiles_initialized" val cardMargin = 10.dp val pageBackgroundGradient = Brush.verticalGradient( listOf( @@ -148,23 +153,23 @@ class HomeRootSection : Routes.Route() { ) } - 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" + internal val changelogClient by lazy { OkHttpClient() } + internal val changelogStableUrl = "https://raw.githubusercontent.com/particle-box/PurrfectSnap/dev/changelogs-stable.txt" + internal val changelogPrereleaseUrl = "https://raw.githubusercontent.com/particle-box/PurrfectSnap/dev/changelogs-prerelease.txt" + internal val announcementsUrl = "https://raw.githubusercontent.com/particle-box/PurrfectSnap/dev/announcements.txt" - private val heroGradientColors = listOf( + internal val heroGradientColors = listOf( Color(0xFF5C4B99), Color(0xFF322B5E), Color(0xFF1B1836) ) - private val quickActionsGradientColors = listOf( + internal val quickActionsGradientColors = listOf( Color(0xFF241C3E), Color(0xFF151127) ) private lateinit var activityLauncherHelper: ActivityLauncherHelper data class QaCard(val id: String, val name: String, val icon: ImageVector, val action: (Routes) -> Unit) - private val cardEntries by lazy { + internal val cardEntries by lazy { val list = mutableListOf() EnumQuickActions.entries.forEach { q -> val name = context.translation["actions.${q.key}.name"] @@ -176,7 +181,7 @@ class HomeRootSection : Routes.Route() { } list } - private val cards by lazy { + internal val cards by lazy { EnumQuickActions.entries.map { (context.translation["actions.${it.key}.name"] to it.icon) to it.action }.associate { @@ -191,7 +196,7 @@ class HomeRootSection : Routes.Route() { } @Composable - private fun rememberPreferenceBool(key: String, default: Boolean = false): State { + internal fun rememberPreferenceBool(key: String, default: Boolean = false): State { val prefs = remember { context.sharedPreferences } val state = remember { mutableStateOf(prefs.getBoolean(key, default)) } DisposableEffect(prefs, key) { @@ -214,13 +219,17 @@ class HomeRootSection : Routes.Route() { onClick: (() -> Unit)? = null, tint: Color = MaterialTheme.colorScheme.onSurfaceVariant, containerColor: Color = MaterialTheme.colorScheme.primary.copy(alpha = 0.08f), + haptic: HapticFeedback? = null, ) { val interactionSource = remember { MutableInteractionSource() } val clickModifier = if (onClick != null) { Modifier.clickable( interactionSource = interactionSource, indication = LocalIndication.current - ) { onClick() } + ) { + haptic?.performHapticFeedback(HapticFeedbackType.LongPress) + onClick() + } } else { Modifier } @@ -244,7 +253,7 @@ class HomeRootSection : Routes.Route() { } @Composable - private fun HeroBadge(text: String) { + internal fun HeroBadge(text: String) { Text( text = text, color = Color.White, @@ -624,7 +633,7 @@ class HomeRootSection : Routes.Route() { onClick = onManageClick, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)), colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White), - modifier = Modifier.align(Alignment.CenterHorizontally) + modifier = Alignment.CenterHorizontally.let { Modifier.align(it) } ) { Icon(Icons.Filled.Settings, contentDescription = null, tint = Color.White) Spacer(modifier = Modifier.width(6.dp)) @@ -686,7 +695,7 @@ class HomeRootSection : Routes.Route() { } } } - private fun resolveTileKey(name: String): String { + internal fun resolveTileKey(name: String): String { val entry = cardEntries.firstOrNull { it.name == name } return entry?.id ?: name } @@ -704,13 +713,13 @@ class HomeRootSection : Routes.Route() { 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) { + internal 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) { + internal fun clearTileOffset(name: String) { val prefs = context.sharedPreferences val key = resolveTileKey(name) prefs.edit().remove("quick_tile_offset_$key").apply() @@ -732,618 +741,23 @@ class HomeRootSection : Routes.Route() { } - @OptIn(ExperimentalLayoutApi::class, ExperimentalAnimationApi::class, ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) - override val content: @Composable (NavBackStackEntry) -> Unit = { - 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)) { - 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 coroutineScope = rememberCoroutineScope() - 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 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 { - 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 - } - } - } - } - - fun loadAnnouncements() { - if (announcementsText != null) return - announcementsLoading = true - announcementsError = null - coroutineScope.launch(Dispatchers.IO) { - 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 - } - } - } - } - - 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) + override val content: @Composable (NavBackStackEntry) -> Unit = { nav -> + val themeId by produceState( + initialValue = context.config.root.global.uiSettings.managerTheme.get() ) { - 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 = onUpdateButtonClick, - channelLabel = channelLabel, - isPurrAuraActive = isPurrAuraActive, - 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, - ) - 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), - 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), - verticalArrangement = Arrangement.spacedBy(10.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - 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(tileWidth) - .aspectRatio(1.05f) - .scaleOnPress(interactionSource) - .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(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 = card.first, - lineHeight = 16.sp, - fontSize = 13.sp, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center, - color = Color.White, - overflow = TextOverflow.Ellipsis, - maxLines = 2, - ) - } - } - } - } - } - } - } - } - } - Spacer(modifier = Modifier.height(32.dp)) + while (true) { + delay(300) + value = context.config.root.global.uiSettings.managerTheme.get() } } - - 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"], - text = "", - icon = Icons.Filled.Info, - confirmButtonText = translation["announcements_dialog_close_button"], - onConfirm = { showAnnouncementsDialog = false }, - confirmEnabled = !announcementsLoading, - showCloseButton = false, - customContent = { - 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 (showQuickActionsMenu) { - 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 - ) + key(themeId) { + with(ManagerTheme.fromId(themeId).theme) { + this@HomeRootSection.HomeScreen(nav) + } } } -} -private fun extractChangelogForVersion(raw: String, version: String): String { + internal 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() 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 d0246b01..f174213e 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 @@ -34,8 +34,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues import androidx.core.content.edit import androidx.core.net.toUri import androidx.navigation.NavBackStackEntry @@ -45,7 +43,9 @@ import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.NetworkType import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager +import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import me.eternal.purrfectsnap.R import me.eternal.purrfectsnap.common.action.EnumAction import me.eternal.purrfectsnap.common.bridge.InternalFileHandleType import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState @@ -53,6 +53,7 @@ import me.eternal.purrfectsnap.storage.getAllScopeNotes import me.eternal.purrfectsnap.storage.setAllScopeNotes import me.eternal.purrfectsnap.task.UpdateCheckWorker import me.eternal.purrfectsnap.ui.manager.Routes +import me.eternal.purrfectsnap.ui.manager.ManagerTheme import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette import me.eternal.purrfectsnap.ui.setup.Requirements @@ -68,10 +69,10 @@ import java.util.concurrent.TimeUnit class HomeSettings : Routes.Route() { override val translation by lazy { context.translation.getCategory("manager.sections.home_settings") } - private lateinit var activityLauncherHelper: ActivityLauncherHelper + internal lateinit var activityLauncherHelper: ActivityLauncherHelper private val dialogs by lazy { AlertDialogs(context.translation) } - private fun scheduleUpdateCheck() { + internal fun scheduleUpdateCheck() { val workManager = WorkManager.getInstance(context.androidContext) val updateSettings = context.config.root.global.updateSettings var configDirty = false @@ -128,11 +129,31 @@ class HomeSettings : Routes.Route() { workManager.cancelUniqueWork("purrfectsnap_update_check") } } + override val init: () -> Unit = { activityLauncherHelper = ActivityLauncherHelper(context.activity!!) } + + override val content: @Composable (NavBackStackEntry) -> Unit = { nav -> + val themeId by produceState( + initialValue = context.config.root.global.uiSettings.managerTheme.get() + ) { + while (true) { + delay(300) + value = context.config.root.global.uiSettings.managerTheme.get() + } + } + + key(themeId) { + val currentTheme = ManagerTheme.fromId(themeId).theme + with(currentTheme) { + this@HomeSettings.SettingsScreen(nav) + } + } + } + @Composable - private fun RowTitle(title: String) { + internal fun RowTitle(title: String) { Text( text = title, modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp), @@ -141,8 +162,9 @@ class HomeSettings : Routes.Route() { color = Color.White ) } + @Composable - private fun PremiumPreferenceToggle( + internal fun PremiumPreferenceToggle( sharedPreferences: SharedPreferences, key: String, text: String, @@ -214,7 +236,7 @@ class HomeSettings : Routes.Route() { } @Composable - private fun PreferenceToggle(sharedPreferences: SharedPreferences, key: String, text: String) { + internal fun PreferenceToggle(sharedPreferences: SharedPreferences, key: String, text: String) { val realKey = "debug_$key" var value by remember { mutableStateOf(sharedPreferences.getBoolean(realKey, false)) } val hapticFeedback = LocalHapticFeedback.current @@ -246,7 +268,7 @@ class HomeSettings : Routes.Route() { } @Composable - private fun RowAction(key: String, requireConfirmation: Boolean = false, action: () -> Unit) { + internal fun RowAction(key: String, requireConfirmation: Boolean = false, action: () -> Unit) { var confirmationDialog by remember { mutableStateOf(false) } @@ -294,8 +316,9 @@ class HomeSettings : Routes.Route() { } } } + @Composable - private fun ShiftedRow( + internal fun ShiftedRow( modifier: Modifier = Modifier, horizontalArrangement: Arrangement.Horizontal = Arrangement.Start, verticalAlignment: Alignment.Vertical = Alignment.Top, @@ -308,660 +331,52 @@ class HomeSettings : Routes.Route() { ) { content(this) } } - @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) - override val content: @Composable (NavBackStackEntry) -> Unit = { - val contextC = LocalContext.current - val scope = rememberCoroutineScope() - val scrollState = rememberScrollState() - val positiveLabel = context.translation["button.positive"] - val negativeLabel = context.translation["button.negative"] - val importLabel = context.translation["button.import"] - val sharedButtonColors = ButtonDefaults.buttonColors( - containerColor = Color.White.copy(alpha = 0.12f), + @Composable + internal fun GlassCard( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit + ) { + Surface( + modifier = modifier, + 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)), contentColor = Color.White - ) - val sharedOutlinedColors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White) - var showResetSetupDialog by remember { mutableStateOf(false) } - - @Composable - fun GlassCard( - modifier: Modifier = Modifier, - content: @Composable ColumnScope.() -> Unit ) { - Surface( - modifier = modifier, - 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)), - contentColor = Color.White - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - content() - } - } - } - - @Composable - fun AestheticDropdownField( - value: String, - expanded: Boolean, - modifier: Modifier = Modifier, - onClick: () -> Unit - ) { - val shape = RoundedCornerShape(16.dp) - Row( - modifier = modifier - .clip(shape) - .background(Color.White.copy(alpha = 0.06f)) - .border(1.dp, Color.White.copy(alpha = 0.16f), shape) - .clickable { onClick() } - .padding(horizontal = 14.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text(text = value, color = Color.White) - ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) - } - } - - val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() - Box( - modifier = Modifier - .fillMaxSize() - .background(PurrfectPalette.backgroundGradient) - ) { - if (showResetSetupDialog) { - AestheticDialog( - onDismissRequest = { showResetSetupDialog = false }, - title = translation["reset_setup_dialog_title"], - text = translation["reset_setup_dialog_text"], - icon = Icons.Filled.Warning, - confirmButtonText = positiveLabel, - dismissButtonText = negativeLabel, - onConfirm = { - showResetSetupDialog = false - context.sharedPreferences.edit() - .remove("setup_in_progress") - .remove("setup_current_route") - .remove("setup_skip_patch") - .remove("setup_install_mode") - .apply() - - context.config.reset() - context.config.writeConfig() - - val intent = android.content.Intent( - context.androidContext, - me.eternal.purrfectsnap.ui.setup.SetupActivity::class.java - ) - intent.flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or - android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK - context.androidContext.startActivity(intent) - routes.navController.popBackStack() - }, - onDismiss = { showResetSetupDialog = false }, - showCloseButton = false - ) - } Column( - modifier = Modifier.fillMaxSize() + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) ) { - Spacer(modifier = Modifier.height(topPadding)) - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 12.dp), - shape = RoundedCornerShape(26.dp), - color = Color.White.copy(alpha = 0.07f), - tonalElevation = 0.dp, - shadowElevation = 0.dp, - border = BorderStroke( - 1.dp, - Brush.linearGradient( - listOf( - PurrfectPalette.glowPrimary.copy(alpha = 0.55f), - PurrfectPalette.glowSecondary.copy(alpha = 0.35f) - ) - ) - ), - contentColor = Color.White - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - IconButton(onClick = { routes.navController.popBackStack() }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - tint = Color.White - ) - } - Text( - text = translation["manager.routes.home_settings"], - fontWeight = FontWeight.ExtraBold, - fontSize = 18.sp - ) - IconButton(onClick = { routes.navigation?.openBottomBarCustomization = true }) { - Icon( - imageVector = Icons.Filled.Tune, - contentDescription = null, - tint = Color.White.copy(alpha = 0.85f) - ) - } - } - } - - Spacer(modifier = Modifier.height(8.dp)) - - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(scrollState) - ) { - 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 -> - RowAction(key = enumAction.key) { context.launchActionIntent(enumAction) } - } - RowAction(key = "regen_mappings") { context.checkForRequirements(Requirements.MAPPINGS) } - RowAction(key = "change_language") { context.checkForRequirements(Requirements.LANGUAGE) } - } - - GlassCard { - RowTitle(title = translation["ui_settings_title"]) - ShiftedRow { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 55.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text(text = translation["haptic_feedback_label"]) - var hapticFeedbackEnabled by remember { mutableStateOf(context.config.root.global.uiSettings.hapticFeedback.getNullable() ?: true) } - val hapticFeedback = LocalHapticFeedback.current - Switch( - checked = hapticFeedbackEnabled, - onCheckedChange = { - if (it) { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - } - hapticFeedbackEnabled = it - context.config.root.global.uiSettings.hapticFeedback.set(it) - context.config.writeConfig() - }, - modifier = Modifier.padding(end = 26.dp), - colors = purrfectSwitchColors() - ) - } - Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 55.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text(text = translation["use_system_toasts_label"]) - var useSystemToasts by remember { mutableStateOf(context.config.root.global.uiSettings.useSystemToasts.getNullable() ?: false) } - val hapticFeedback = LocalHapticFeedback.current - Switch( - checked = useSystemToasts, - onCheckedChange = { - if (context.config.root.global.uiSettings.hapticFeedback.get()) { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - } - useSystemToasts = it - context.config.root.global.uiSettings.useSystemToasts.set(it) - context.config.writeConfig() - }, - modifier = Modifier.padding(end = 26.dp), - colors = purrfectSwitchColors() - ) - } - } - } - } - - GlassCard { - RowTitle(title = translation["updates_title"]) - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - var autoUpdateCheck by remember { mutableStateOf(context.config.root.global.updateSettings.autoUpdateCheck.getNullable() ?: true) } - var selectedChannel by remember { mutableStateOf(context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable") } - var channelMenuExpanded by remember { mutableStateOf(false) } - ShiftedRow { - Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 55.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text(text = translation["auto_update_check"]) - val hapticFeedback = LocalHapticFeedback.current - Switch( - checked = autoUpdateCheck, - onCheckedChange = { - if (context.config.root.global.uiSettings.hapticFeedback.get()) { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - } - autoUpdateCheck = it - context.config.root.global.updateSettings.autoUpdateCheck.set(it) - context.config.writeConfig() - scheduleUpdateCheck() - }, - modifier = Modifier.padding(end = 26.dp), - colors = purrfectSwitchColors() - ) - } - } - AnimatedVisibility(visible = autoUpdateCheck) { - val spacingModifier = Modifier - .fillMaxWidth() - .padding(horizontal = 26.dp) - - ExposedDropdownMenuBox( - expanded = channelMenuExpanded, - onExpandedChange = { channelMenuExpanded = it }, - modifier = spacingModifier - ) { - AestheticDropdownField( - value = translation.getOrNull("update_channel_${selectedChannel}") ?: selectedChannel, - expanded = channelMenuExpanded, - modifier = Modifier - .fillMaxWidth() - .menuAnchor(MenuAnchorType.PrimaryNotEditable), - onClick = { channelMenuExpanded = true } - ) - ExposedDropdownMenu( - expanded = channelMenuExpanded, - onDismissRequest = { channelMenuExpanded = false } - ) { - listOf("stable", "prerelease").forEach { channel -> - DropdownMenuItem( - text = { Text(text = translation.getOrNull("update_channel_${channel}") ?: channel) }, - onClick = { - selectedChannel = channel - channelMenuExpanded = false - context.config.root.global.updateSettings.updateChannel.set(channel) - context.config.writeConfig() - scheduleUpdateCheck() - } - ) - } - } - } - } - } - } - - GlassCard { - RowTitle(title = translation["reset_setup_title"]) - ShiftedRow( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 55.dp) - .clickable { - showResetSetupDialog = true - }, - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = translation["reset_setup_action"], - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - lineHeight = 20.sp - ) - Icon( - imageVector = Icons.AutoMirrored.Filled.OpenInNew, - contentDescription = translation["reset_setup_action"], - modifier = Modifier.padding(end = 14.dp) - ) - } - } - - GlassCard { - RowTitle(title = translation["message_logger_title"]) - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - var storedMessagesCount by rememberAsyncMutableState(defaultValue = 0) { - context.messageLogger.getStoredMessageCount() - } - var storedStoriesCount by rememberAsyncMutableState(defaultValue = 0) { - context.messageLogger.getStoredStoriesCount() - } - var showImportDialog by remember { mutableStateOf(false) } - Column( - modifier = Modifier - .fillMaxWidth() - .padding(5.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - val summary = translation.format( - "message_logger_summary", - "messageCount" to storedMessagesCount.toString(), - "storyCount" to storedStoriesCount.toString() - ).replace("\n", " | ") - Text( - summary, - maxLines = 2, - color = Color.White, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() - ) - FlowRow( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.CenterHorizontally), - horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - Button( - onClick = { - runCatching { - activityLauncherHelper.saveFile("message_logger.db", "application/octet-stream") { uri -> - context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { outputStream -> - context.messageLogger.databaseFile.inputStream().use { inputStream -> - inputStream.copyTo(outputStream) - } - } - } - }.onFailure { - context.log.error("Failed to export database", it) - context.longToast(translation.format("export_database_failed_toast", "message" to (it.localizedMessage ?: ""))) - } - }, - colors = sharedButtonColors, - border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)) - ) { - Text(text = translation["export_button"]) - } - Button( - onClick = { - runCatching { - activityLauncherHelper.openFile("application/octet-stream") { uri -> - val tempFile = File(context.androidContext.cacheDir, "view_message_logger.db") - context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { inputStream -> - FileOutputStream(tempFile).use { outputStream -> - inputStream.copyTo(outputStream) - } - } - routes.viewLoggerHistory.navigate { - put("uri", URLEncoder.encode(tempFile.toUri().toString(), "UTF-8")) - } - } - }.onFailure { - context.log.error("Failed to open file", it) - context.longToast( - translation.format( - "open_file_failed_toast", - "message" to (it.localizedMessage ?: "") - ) - ) - } - }, - colors = sharedButtonColors, - border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)) - ) { - Text(text = translation["view_button"]) - } - Button( - onClick = { - runCatching { - context.messageLogger.purgeAll() - storedMessagesCount = 0 - storedStoriesCount = 0 - }.onFailure { - context.log.error("Failed to clear messages", it) - context.longToast(translation.format("clear_messages_failed_toast", "message" to (it.localizedMessage ?: ""))) - }.onSuccess { - context.shortToast(translation["success_toast"]) - } - }, - colors = sharedButtonColors, - border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)) - ) { - Text(text = translation["clear_button"]) - } - Button( - onClick = { showImportDialog = true }, - colors = sharedButtonColors, - border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)) - ) { - Text(text = translation["import_button"]) - } - } - } - OutlinedButton( - modifier = Modifier - .fillMaxWidth() - .padding(5.dp), - onClick = { routes.loggerHistory.navigate() }, - colors = sharedOutlinedColors, - border = BorderStroke(1.dp, Color.White.copy(alpha = 0.2f)) - ) { - Text(translation["view_logger_history_button"]) - } - if (showImportDialog) { - AestheticDialog( - onDismissRequest = { showImportDialog = false }, - title = translation["message_logger_import_title"], - text = translation["message_logger_import_text"], - icon = Icons.Filled.Info, - confirmButtonText = importLabel, - dismissButtonText = context.translation["button.cancel"], - onConfirm = { - showImportDialog = false - runCatching { - activityLauncherHelper.openFile("application/octet-stream") { uri -> - runCatching { - context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { inputStream -> - context.messageLogger.databaseFile.outputStream().use { outputStream -> - inputStream.copyTo(outputStream) - } - } ?: throw IllegalStateException("Unable to open selected file") - storedMessagesCount = context.messageLogger.getStoredMessageCount() - storedStoriesCount = context.messageLogger.getStoredStoriesCount() - context.shortToast(translation["success_toast"]) - context.log.info("Imported message logger from $uri", "MessageLogger") - }.onFailure { - context.log.error("Failed to import message logger", it) - context.longToast( - translation.format( - "import_failed_toast", - "message" to (it.localizedMessage ?: it.message ?: "") - ) - ) - } - } - }.onFailure { - context.log.error("Failed to launch import picker", it) - context.longToast( - translation.format( - "import_failed_toast", - "message" to (it.localizedMessage ?: it.message ?: "") - ) - ) - } - }, - onDismiss = { showImportDialog = false }, - showCloseButton = false - ) - } - } - } - - GlassCard { - RowTitle(title = translation["friend_notes_title"]) - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text( - text = translation["friend_notes_description"], - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 5.dp), - color = Color.White, - textAlign = TextAlign.Center - ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 5.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - Button(onClick = { - runCatching { - val notes = context.database.getAllScopeNotes() - if (notes.isEmpty()) { - context.shortToast(translation["friend_notes_no_notes_to_backup"]) - return@runCatching - } - val json = context.gson.toJson(notes) - activityLauncherHelper.saveFile("friend_notes_backup.json", "application/json") { uri -> - context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { - it.write(json.toByteArray()) - } - context.shortToast(translation["friend_notes_backup_success"]) - } - }.onFailure { - context.log.error("Failed to backup notes", it) - context.longToast(translation.format("friend_notes_backup_failure", "error" to (it.localizedMessage ?: ""))) - } - }, - colors = sharedButtonColors, - border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)) - ) { - Text(text = translation["backup_button"]) - } - Button(onClick = { - runCatching { - activityLauncherHelper.openFile("application/json") { uri -> - context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { - val json = it.reader().readText() - val notes = context.gson.fromJson>(json, object : com.google.gson.reflect.TypeToken>() {}.type) - context.database.setAllScopeNotes(notes) - context.shortToast(translation["friend_notes_restore_success"]) - } - } - }.onFailure { - context.log.error("Failed to restore notes", it) - context.longToast(translation.format("friend_notes_restore_failure", "error" to (it.localizedMessage ?: ""))) - } - }, - colors = sharedButtonColors, - border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)) - ) { - Text(text = translation["restore_button"]) - } - } - } - } - } - - GlassCard { - RowTitle(title = translation["debug_title"]) - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp) - ) { - var selectedFileType by remember { mutableStateOf(InternalFileHandleType.entries.first()) } - Box(modifier = Modifier.weight(1f)) { - var expanded by remember { mutableStateOf(false) } - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { expanded = it }, - modifier = Modifier.fillMaxWidth() - ) { - AestheticDropdownField( - value = translation.getOrNull("debug_file_${selectedFileType.name.lowercase()}") ?: selectedFileType.fileName, - expanded = expanded, - modifier = Modifier - .fillMaxWidth() - .menuAnchor(MenuAnchorType.PrimaryNotEditable), - onClick = { expanded = true } - ) - ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - InternalFileHandleType.entries.forEach { fileType -> - DropdownMenuItem(onClick = { - expanded = false - selectedFileType = fileType - }, text = { - Text(text = translation.getOrNull("debug_file_${fileType.name.lowercase()}") ?: fileType.fileName) - }) - } - } - } - } - Button( - onClick = { - runCatching { - context.coroutineScope.launch { - selectedFileType.resolve(context.androidContext).delete() - } - }.onFailure { - context.log.error("Failed to clear file", it) - context.longToast(translation.format("clear_file_failed_toast", "message" to (it.localizedMessage ?: ""))) - }.onSuccess { - context.shortToast(translation["success_toast"]) - } - }, - colors = ButtonDefaults.buttonColors( - containerColor = Color.White.copy(alpha = 0.1f), - contentColor = Color.White - ), - 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)) - Spacer(modifier = Modifier.width(8.dp)) - Text(translation["clear_button"]) - } - } - ShiftedRow { - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - PremiumPreferenceToggle( - context.sharedPreferences, - key = "test_mode", - text = translation["test_mode_label"], - defaultValue = true, - confirmDisableTitle = translation["purr_aura_disable_title"], - confirmDisableText = translation["purr_aura_disable_text"] - ) - PreferenceToggle(context.sharedPreferences, key = "disable_feature_loading", text = translation["disable_feature_loading_label"]) - PreferenceToggle(context.sharedPreferences, key = "disable_mapper", text = translation["disable_auto_mapper_label"]) - PreferenceToggle(context.sharedPreferences, key = "disable_bypass_indicator", text = translation["disable_bypass_indicator_label"]) - } - } - } - } - - Spacer(modifier = Modifier.height(routes.bottomPadding + 12.dp)) - } + content() } } } -} + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + internal fun AestheticDropdownField( + value: String, + expanded: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit + ) { + val shape = RoundedCornerShape(16.dp) + Row( + modifier = modifier + .clip(shape) + .background(Color.White.copy(alpha = 0.06f)) + .border(1.dp, Color.White.copy(alpha = 0.16f), shape) + .clickable { onClick() } + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(text = value, color = Color.White) + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + } + } } diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/RetroGameScreen.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/RetroGameScreen.kt index c0fdbc56..215d2c52 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/RetroGameScreen.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/RetroGameScreen.kt @@ -155,6 +155,7 @@ class RetroGameScreen : Routes.Route() { } LaunchedEffect(Unit) { + context.shortToast(routes.homeAbout.translation["about_magic_toast"]) resetGame() while (true) { delay(16) 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 7e54f610..2b65a481 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 @@ -30,7 +30,9 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog import androidx.documentfile.provider.DocumentFile +import androidx.navigation.NavBackStackEntry import kotlinx.coroutines.* import me.eternal.purrfectsnap.common.scripting.type.ModuleInfo import me.eternal.purrfectsnap.common.scripting.ui.EnumScriptInterface @@ -44,11 +46,11 @@ import me.eternal.purrfectsnap.common.util.ktx.openLink import me.eternal.purrfectsnap.storage.isScriptEnabled import me.eternal.purrfectsnap.storage.setScriptEnabled import me.eternal.purrfectsnap.ui.manager.Routes +import me.eternal.purrfectsnap.ui.manager.ManagerTheme 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.ActivityLauncherHelper -import me.eternal.purrfectsnap.ui.util.Dialog import me.eternal.purrfectsnap.ui.util.chooseFolder import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors import me.eternal.purrfectsnap.ui.util.pullrefresh.PullRefreshIndicator @@ -57,15 +59,15 @@ import me.eternal.purrfectsnap.ui.util.pullrefresh.rememberPullRefreshState 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) + internal lateinit var activityLauncherHelper: ActivityLauncherHelper + internal val reloadDispatcher = AsyncUpdateDispatcher(updateOnFirstComposition = false) + internal var selectedTab by mutableStateOf(0) override val init: () -> Unit = { activityLauncherHelper = ActivityLauncherHelper(context.activity!!) } - suspend fun isScriptInstalledByUrl(scriptUrl: String): Boolean { + internal suspend fun isScriptInstalledByUrl(scriptUrl: String): Boolean { return try { val installedScripts = context.scriptManager.getSyncedModules() installedScripts.any { module -> @@ -76,7 +78,7 @@ class ScriptingRootSection : Routes.Route() { } } - fun downloadScript(scriptUrl: String, onComplete: () -> Unit) { + internal fun downloadScript(scriptUrl: String, onComplete: () -> Unit) { context.coroutineScope.launch { if (isScriptInstalledByUrl(scriptUrl)) { context.shortToast(translation["script_already_installed"]) @@ -97,7 +99,7 @@ class ScriptingRootSection : Routes.Route() { } @Composable - private fun ImportRemoteScript( + internal fun ImportRemoteScript( dismiss: () -> Unit ) { var url by remember { mutableStateOf("") } @@ -172,7 +174,7 @@ class ScriptingRootSection : Routes.Route() { } @Composable - private fun ModuleActions( + internal fun ModuleActions( script: ModuleInfo, canUpdate: Boolean, dismiss: () -> Unit @@ -270,7 +272,7 @@ class ScriptingRootSection : Routes.Route() { } @Composable - fun ModuleItem(script: ModuleInfo) { + internal fun ModuleItem(script: ModuleInfo) { var enabled by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(script)) { context.database.isScriptEnabled(script.name) } @@ -430,7 +432,7 @@ class ScriptingRootSection : Routes.Route() { override val floatingActionButton: @Composable () -> Unit = {} @Composable - private fun SelectFolderButton(onClick: () -> Unit) { + internal fun SelectFolderButton(onClick: () -> Unit) { val label = translation["select_folder_button"] Box( modifier = Modifier @@ -483,7 +485,7 @@ class ScriptingRootSection : Routes.Route() { } @Composable - fun ScriptSettings(script: ModuleInfo) { + internal fun ScriptSettings(script: ModuleInfo) { val settingsInterface = remember { val module = context.scriptManager.runtime.getModuleByName(script.name) ?: return@remember null @@ -500,84 +502,23 @@ class ScriptingRootSection : Routes.Route() { } } - override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = { - val scriptingFolder by rememberAsyncMutableState( - defaultValue = null, - updateDispatcher = reloadDispatcher - ) { context.scriptManager.getScriptsFolder() } - val tabTitles = listOf(translation["installed_scripts_tab"], translation["catalog_tab"]) - var showImportDialog by remember { mutableStateOf(false) } - var showToast by remember { mutableStateOf(false) } - - LaunchedEffect(scriptingFolder) { - if (scriptingFolder == null && selectedTab != 0) { - selectedTab = 0 + override val content: @Composable (NavBackStackEntry) -> Unit = { nav -> + val themeId by produceState(initialValue = context.config.root.global.uiSettings.managerTheme.get()) { + while (true) { + delay(300) + value = context.config.root.global.uiSettings.managerTheme.get() } } - if (showImportDialog) { - ImportRemoteScript { showImportDialog = false } - } - if (showToast) { - LaunchedEffect(showToast) { - context.shortToast(translation["select_scripts_folder_toast"]) - showToast = false - } - } - - Column( - modifier = Modifier - .fillMaxSize() - .background(PurrfectPalette.backgroundGradient) - ) { - ScriptingHeader( - titles = tabTitles, - selectedTab = selectedTab, - onTabSelected = { index -> - if (index == 1 && scriptingFolder == null) { - showToast = true - } else { - selectedTab = index - } - }, - onImport = { - if (scriptingFolder == null) showToast = true else showImportDialog = true - }, - onOpenFolder = { - if (scriptingFolder == null) { - showToast = true - } else { - scriptingFolder?.let { - context.androidContext.openLink( - it.uri.toString(), - context.translation["toast_open_link_failed"] - ) - } - } - }, - onManageRepos = { routes.manageScriptRepos.navigate() }, - onDocs = { - context.androidContext.openLink( - "https://github.com/SnapEnhance/scripting-docs", - context.translation["toast_open_link_failed"] - ) - }, - folderSelected = scriptingFolder != null - ) - Spacer(Modifier.height(12.dp)) - when (selectedTab) { - 0 -> InstalledTabContent( - scriptingFolder = scriptingFolder - ) - 1 -> CatalogTabContent( - scriptingFolder = scriptingFolder - ) + key(themeId) { + with(ManagerTheme.fromId(themeId).theme) { + this@ScriptingRootSection.ScriptingScreen(nav) } } } @Composable - private fun InstalledTabContent( + internal fun InstalledTabContent( scriptingFolder: DocumentFile? ) { val scriptModules by rememberAsyncMutableState( @@ -800,7 +741,7 @@ class ScriptingRootSection : Routes.Route() { } @Composable - private fun CatalogTabContent( + internal fun CatalogTabContent( scriptingFolder: DocumentFile? ) { val coroutineScope = rememberCoroutineScope() @@ -833,7 +774,7 @@ class ScriptingRootSection : Routes.Route() { } @Composable - private fun ScriptingHeader( + internal fun ScriptingHeader( titles: List, selectedTab: Int, onTabSelected: (Int) -> Unit, @@ -911,7 +852,7 @@ class ScriptingRootSection : Routes.Route() { } @Composable - private fun ScriptingTabSwitcher( + internal fun ScriptingTabSwitcher( titles: List, selectedTab: Int, onTabSelected: (Int) -> Unit 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 0ab8a31d..c842d32e 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 @@ -35,6 +35,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavBackStackEntry +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import me.eternal.purrfectsnap.R import me.eternal.purrfectsnap.common.ReceiversConfig @@ -46,21 +47,22 @@ import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie import me.eternal.purrfectsnap.common.util.snap.SnapWidgetBroadcastReceiverHelper import me.eternal.purrfectsnap.storage.* import me.eternal.purrfectsnap.ui.manager.Routes +import me.eternal.purrfectsnap.ui.manager.ManagerTheme import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette import me.eternal.purrfectsnap.ui.util.coil.BitmojiImage class SocialRootSection : Routes.Route() { - private var friendList: List by mutableStateOf(emptyList()) - private var groupList: List by mutableStateOf(emptyList()) + internal var friendList: List by mutableStateOf(emptyList()) + internal var groupList: List by mutableStateOf(emptyList()) - private fun updateScopeLists() { + internal fun updateScopeLists() { context.coroutineScope.launch { friendList = context.database.getFriends(descOrder = true) groupList = context.database.getGroups() } } - private fun requestLatestSnapshot() { + internal fun requestLatestSnapshot() { runCatching { context.androidContext.sendBroadcast( SnapWidgetBroadcastReceiverHelper.create(ReceiversConfig.BRIDGE_SYNC_ACTION) {} @@ -71,7 +73,7 @@ class SocialRootSection : Routes.Route() { } @Composable - private fun ScopeList( + internal fun ScopeList( scope: SocialScope, friends: List, groups: List @@ -88,7 +90,6 @@ class SocialRootSection : Routes.Route() { contentPadding = PaddingValues(start = 10.dp, end = 10.dp, bottom = routes.bottomPadding + 12.dp), verticalArrangement = Arrangement.spacedBy(10.dp) ) { - //check if scope list is empty val listSize = list.size if (listSize == 0) { @@ -197,147 +198,23 @@ class SocialRootSection : Routes.Route() { } } - @OptIn(ExperimentalFoundationApi::class) - override val content: @Composable (NavBackStackEntry) -> Unit = { - val titles = remember { - listOf(translation["friends_tab"], translation["groups_tab"]) - } - val coroutineScope = rememberCoroutineScope() - val pagerState = rememberPagerState { titles.size } - var searchQuery by rememberSaveable { mutableStateOf("") } - var searchActive by rememberSaveable { mutableStateOf(false) } - - LaunchedEffect(Unit) { - context.database.receiveMessagingDataCallback = { friends, groups -> - friendList = friends - groupList = groups - } - updateScopeLists() - requestLatestSnapshot() - } - DisposableEffect(Unit) { - onDispose { - context.database.receiveMessagingDataCallback = { _, _ -> } - } - } - val normalizedQuery = remember(searchQuery) { searchQuery.trim() } - val filteredFriends = remember(friendList, normalizedQuery) { - if (normalizedQuery.isBlank()) { - friendList - } else { - friendList.filter { - it.mutableUsername.contains(normalizedQuery, ignoreCase = true) || - it.displayName?.contains(normalizedQuery, ignoreCase = true) == true - } - } - } - val filteredGroups = remember(groupList, normalizedQuery) { - if (normalizedQuery.isBlank()) { - groupList - } else { - groupList.filter { it.name.contains(normalizedQuery, ignoreCase = true) } + override val content: @Composable (NavBackStackEntry) -> Unit = { nav -> + val themeId by produceState(initialValue = context.config.root.global.uiSettings.managerTheme.get()) { + while (true) { + delay(300) + value = context.config.root.global.uiSettings.managerTheme.get() } } - Column( - modifier = Modifier - .fillMaxSize() - .background(PurrfectPalette.backgroundGradient) - ) { - SocialHeader( - titles = titles, - pagerState = pagerState, - onTabSelected = { index -> - coroutineScope.launch { pagerState.animateScrollToPage(index) } - }, - friendCount = friendList.size, - groupCount = groupList.size, - searchActive = searchActive, - onSearchToggle = { - searchActive = !searchActive - if (!searchActive) searchQuery = "" - } - ) - if (searchActive) { - val searchHint = context.translation["manager.dialogs.add_friend.search_hint"] - val searchShape = RoundedCornerShape(18.dp) - val searchBorder = Brush.linearGradient( - listOf( - PurrfectPalette.glowPrimary.copy(alpha = 0.45f), - PurrfectPalette.glowSecondary.copy(alpha = 0.35f) - ) - ) - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 6.dp), - shape = searchShape, - color = Color.White.copy(alpha = 0.05f), - border = BorderStroke(1.dp, searchBorder), - tonalElevation = 0.dp, - shadowElevation = 0.dp - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .background(PurrfectPalette.cardOverlay, searchShape) - .padding(horizontal = 14.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { - Icon( - imageVector = Icons.Filled.Search, - contentDescription = searchHint, - tint = PurrfectPalette.textSecondary - ) - BasicTextField( - value = searchQuery, - onValueChange = { searchQuery = it }, - singleLine = true, - textStyle = MaterialTheme.typography.bodyMedium.copy( - color = Color.White, - fontSize = 15.sp - ), - cursorBrush = SolidColor(PurrfectPalette.glowSecondary), - modifier = Modifier.weight(1f) - ) { innerTextField -> - if (searchQuery.isEmpty()) { - Text( - text = searchHint, - color = PurrfectPalette.textSecondary, - fontSize = 14.sp - ) - } - innerTextField() - } - if (searchQuery.isNotEmpty()) { - IconButton(onClick = { searchQuery = "" }) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = translation["clear_search_button_description"], - tint = Color.White - ) - } - } - } - } - } - Spacer(Modifier.height(12.dp)) - HorizontalPager( - modifier = Modifier - .fillMaxSize(), - state = pagerState - ) { page -> - when (page) { - 0 -> ScopeList(SocialScope.FRIEND, filteredFriends, filteredGroups) - 1 -> ScopeList(SocialScope.GROUP, filteredFriends, filteredGroups) - } + key(themeId) { + with(ManagerTheme.fromId(themeId).theme) { + this@SocialRootSection.SocialScreen(nav) } } } @Composable - private fun SocialCard( + internal fun SocialCard( scope: SocialScope, friend: MessagingFriendInfo?, group: MessagingGroupInfo?, @@ -500,7 +377,7 @@ class SocialRootSection : Routes.Route() { } @Composable - private fun SocialHeader( + internal fun SocialHeader( titles: List, pagerState: androidx.compose.foundation.pager.PagerState, onTabSelected: (Int) -> Unit, @@ -574,7 +451,7 @@ class SocialRootSection : Routes.Route() { } @Composable - private fun SocialTabSwitcher( + internal fun SocialTabSwitcher( titles: List, pagerState: androidx.compose.foundation.pager.PagerState, onTabSelected: (Int) -> Unit @@ -620,7 +497,7 @@ class SocialRootSection : Routes.Route() { } @Composable - private fun EmptyState(scope: SocialScope) { + internal fun EmptyState(scope: SocialScope) { val title = when (scope) { SocialScope.FRIEND -> translation.getOrNull("friends_empty_title") ?: translation["empty_hint"] SocialScope.GROUP -> translation.getOrNull("groups_empty_title") ?: translation["empty_hint"] @@ -647,13 +524,13 @@ class SocialRootSection : Routes.Route() { modifier = Modifier.size(26.dp) ) Text( - text = title, + text = title ?: "", color = Color.White, fontWeight = FontWeight.SemiBold, fontSize = 15.sp ) Text( - text = translation["social_empty_hint"], + text = translation["social_empty_hint"] ?: "", color = PurrfectPalette.textSecondary, fontSize = 12.sp ) @@ -662,7 +539,7 @@ class SocialRootSection : Routes.Route() { } @Composable - private fun StatPill(label: String, value: Int) { + internal fun StatPill(label: String, value: Int) { Surface( shape = RoundedCornerShape(50), color = Color.White.copy(alpha = 0.08f), diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionAboutView.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionAboutView.kt new file mode 100644 index 00000000..1f144a68 --- /dev/null +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionAboutView.kt @@ -0,0 +1,237 @@ +package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion + +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.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.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +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.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 +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextIndent +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavBackStackEntry +import me.eternal.purrfectsnap.R +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.pages.home.HomeAbout +import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette +import me.eternal.purrfectsnap.ui.util.PurrfectMarqueeText +import me.eternal.purrfectsnap.ui.util.headerHeightTracker +import me.eternal.purrfectsnap.ui.util.scaleOnPress + +@Composable +fun HomeAbout.AphelionAboutScreen(nav: NavBackStackEntry) { + val avenirNext = remember { + FontFamily(Font(R.font.avenir_next_medium, FontWeight.Medium)) + } + val scrollState = rememberScrollState() + val aboutStory = remember { translation["about_story"]?.trim() ?: "" } + val horizontalPadding = 24.dp + val bottomPadding = routes.bottomPadding + 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(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 + 4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Surface( + modifier = Modifier + .padding(horizontal = horizontalPadding) + .fillMaxWidth(), + shape = RoundedCornerShape(30.dp), + color = Color.Transparent, + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)), + tonalElevation = 0.dp, + shadowElevation = 0.dp + ) { + Box(modifier = Modifier.fillMaxWidth().background(PurrfectPalette.panelGradient)) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 22.dp, vertical = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = translation["about_title"] ?: "About", + fontSize = 32.sp, + fontWeight = FontWeight.ExtraBold, + color = PurrfectPalette.textPrimary, + fontFamily = avenirNext, + modifier = Modifier.clickable( + interactionSource = tapSource, + indication = null, + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + val now = SystemClock.elapsedRealtime() + if (now - lastTapTime.longValue > tapTimeoutMs) { + tapCount.intValue = 0 + } + tapCount.intValue += 1 + lastTapTime.longValue = now + if (tapCount.intValue >= 3 && tapCount.intValue < 5) { + context.shortToast(translation.format("magic_toast", "count" to (5 - tapCount.intValue).toString())) + } + if (tapCount.intValue >= 5) { + tapCount.intValue = 0 + routes.retroGame.navigate() + } + } + ) + ) + Text( + text = translation["about_tagline"] ?: "", + fontSize = 14.sp, + color = PurrfectPalette.textSecondary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = translation["about_lead_developers_title"] ?: "Lead Developers", + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White, + modifier = Modifier.padding(bottom = 4.dp) + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically + ) { + DeveloperCard(name = "ΞTΞRNAL", imageRes = R.drawable.pfp_external, avenirNext = avenirNext, modifier = Modifier.weight(1f)) + DeveloperCard(name = "", imageRes = R.drawable.pfp_rsr, avenirNext = avenirNext, modifier = Modifier.weight(1f)) + } + } + } + } + + // LITERAL OUR STORY VERBATIM RESTORATION (Justified & Indented) + Surface( + modifier = Modifier + .padding(horizontal = horizontalPadding) + .fillMaxWidth(), + shape = RoundedCornerShape(26.dp), + color = PurrfectPalette.cardOverlayColor, + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)), + tonalElevation = 0.dp, + shadowElevation = 0.dp + ) { + Column( + modifier = Modifier.padding(horizontal = 20.dp, vertical = 20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + PurrfectMarqueeText( + text = translation["about_story_title"] ?: "About Us", + color = Color.White, + style = TextStyle( + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + ) + + HorizontalDivider( + modifier = Modifier.fillMaxWidth(0.7f).padding(vertical = 4.dp), + thickness = 1.2.dp, + color = Color.White.copy(alpha = 0.3f) + ) + + Text( + text = aboutStory, + fontSize = 15.sp, + color = PurrfectPalette.textSecondary, + textAlign = TextAlign.Justify, + style = LocalTextStyle.current.copy( + lineHeight = 22.sp, + textIndent = TextIndent(firstLine = 24.sp) + ), + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp) + ) + } + } + + Surface( + modifier = Modifier + .padding(horizontal = horizontalPadding) + .fillMaxWidth(), + shape = RoundedCornerShape(24.dp), + color = PurrfectPalette.cardOverlayColor, + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)), + tonalElevation = 0.dp, + shadowElevation = 0.dp + ) { + Column( + modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(text = translation["about_thanks_title"] ?: "Special Thanks", fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = Color.White, textAlign = TextAlign.Center) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + Button(modifier = Modifier.weight(1f), onClick = { context.androidContext.openLink("https://github.com/particle-box/PurrfectSnap", context.translation["toast_open_link_failed"] ?: "") }, colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color(0xFF1B152E)), shape = RoundedCornerShape(14.dp)) { + Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_github), contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = translation["github_button"] ?: "GitHub", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + OutlinedButton(modifier = Modifier.weight(1f), onClick = { context.androidContext.openLink("https://t.me/purrfectsnap_official", context.translation["toast_open_link_failed"] ?: "") }, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.35f)), colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White), shape = RoundedCornerShape(14.dp)) { + Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_telegram), contentDescription = null, modifier = Modifier.size(18.dp), tint = Color.White) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = translation["telegram_button"] ?: "Telegram", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } + Spacer(modifier = Modifier.height(8.dp)) + } + + FloatingTopBar( + title = routeInfo.translatedKey?.value ?: translation["manager.routes.home_about"] ?: "About Us", + 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/themes/aphelion/AphelionFeaturesView.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionFeaturesView.kt new file mode 100644 index 00000000..df5278dc --- /dev/null +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionFeaturesView.kt @@ -0,0 +1,57 @@ +package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion + +import androidx.compose.runtime.* +import androidx.navigation.NavBackStackEntry +import androidx.navigation.compose.currentBackStackEntryAsState +import me.eternal.purrfectsnap.ui.manager.pages.features.FeaturesRootSection +import me.eternal.purrfectsnap.ui.manager.pages.features.FeaturesRootSection.Companion.FEATURE_CONTAINER_ROUTE +import me.eternal.purrfectsnap.ui.manager.pages.features.FeaturesRootSection.Companion.SEARCH_FEATURE_ROUTE +import me.eternal.purrfectsnap.common.config.PropertyKey +import me.eternal.purrfectsnap.common.config.PropertyValue +import me.eternal.purrfectsnap.common.config.PropertyPair +import me.eternal.purrfectsnap.common.config.ConfigContainer + +@Composable +fun FeaturesRootSection.AphelionFeaturesScreen(nav: NavBackStackEntry) { + val navBackStackEntry by routes.navController.currentBackStackEntryAsState() + val currentDestination = navBackStackEntry?.destination + + when (currentDestination?.route) { + FEATURE_CONTAINER_ROUTE -> { + val containerName = navBackStackEntry?.arguments?.getString("name")!! + val propertyPair = allContainers[containerName]!! + Container( + configContainer = propertyPair.value.get() as ConfigContainer, + stateKey = "${routeInfo.id}:container:$containerName", + sectionTitle = context.translation[propertyPair.key.propertyName()], + sectionSubtitle = context.translation[propertyPair.key.propertyDescription()], + onBack = { routes.navController.popBackStack() } + ) + } + SEARCH_FEATURE_ROUTE -> { + val keyword = navBackStackEntry?.arguments?.getString("keyword").orEmpty() + val properties = allProperties.filter { + isSearchVisibleProperty(it.key) && ( + it.key.name.contains(keyword, ignoreCase = true) || + context.translation[it.key.propertyName()].contains(keyword, ignoreCase = true) || + context.translation[it.key.propertyDescription()].contains(keyword, ignoreCase = true) + ) + }.map { PropertyPair(it.key as PropertyKey, it.value as PropertyValue) } + + PropertiesView( + properties = properties, + stateKey = "${routeInfo.id}:search:$keyword", + isSearchResults = true, + searchKeyword = keyword, + enableGlobalSearch = true, + onBack = { navigateToMainRoot() } + ) + } + else -> { + Container( + configContainer = context.config.root, + stateKey = "${routeInfo.id}:container:root" + ) + } + } +} diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionHomeView.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionHomeView.kt new file mode 100644 index 00000000..b6f4128e --- /dev/null +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionHomeView.kt @@ -0,0 +1,787 @@ +package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion + +import android.content.SharedPreferences +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.* +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +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.rememberScrollState +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.outlined.Widgets +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +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.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.HapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalHapticFeedback +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 +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.lerp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex +import androidx.navigation.NavBackStackEntry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.eternal.purrfectsnap.R +import me.eternal.purrfectsnap.common.BuildConfig +import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState +import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList +import me.eternal.purrfectsnap.common.util.ktx.openLink +import me.eternal.purrfectsnap.storage.getQuickTiles +import me.eternal.purrfectsnap.storage.setQuickTiles +import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog +import me.eternal.purrfectsnap.ui.manager.data.UpdateDownloader +import me.eternal.purrfectsnap.ui.manager.data.Updater +import me.eternal.purrfectsnap.ui.manager.data.Updater.Channel +import me.eternal.purrfectsnap.ui.manager.pages.home.HomeRootSection +import me.eternal.purrfectsnap.ui.manager.pages.home.QuickActionsDialog +import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette +import me.eternal.purrfectsnap.ui.util.Motion +import me.eternal.purrfectsnap.ui.util.PurrfectMarqueeText +import me.eternal.purrfectsnap.ui.util.headerHeightTracker +import me.eternal.purrfectsnap.ui.util.scaleOnPress +import okhttp3.OkHttpClient +import okhttp3.Request + +@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) +@Composable +fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) { + + @Composable + fun LivingPurrAura(isActive: Boolean, haptic: HapticFeedback) { + val infiniteTransition = rememberInfiniteTransition(label = "aura") + val pulseScale by infiniteTransition.animateFloat( + initialValue = 0.88f, targetValue = 1.12f, + animationSpec = infiniteRepeatable(tween(1600, easing = EaseInOutSine), RepeatMode.Reverse), + label = "pulse" + ) + 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, + animationSpec = infiniteRepeatable(tween(3200, delayMillis = 1100, easing = LinearEasing), RepeatMode.Restart), + label = "g2" + ) + val glow3 by infiniteTransition.animateFloat( + initialValue = 0f, targetValue = 1f, + animationSpec = infiniteRepeatable(tween(3200, delayMillis = 2200, easing = LinearEasing), RepeatMode.Restart), + label = "g3" + ) + 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 = "secondaryColor" + ) + 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) + drawCircle( + brush = Brush.radialGradient( + colors = listOf(coreColor, secondaryColor), + center = center, radius = baseRadius * pulseScale + ), + radius = baseRadius * pulseScale, center = center + ) + 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) + ) + } + } + + @Composable + fun AphelionTopBarActionChip( + icon: ImageVector, + label: String? = null, + contentDescription: String? = label, + shrinkFactor: Float = 1f, + haptic: HapticFeedback, + onClick: () -> Unit, + ) { + Surface( + modifier = Modifier.height(36.dp).widthIn(min = 36.dp), + 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) + ) + ) + ) + ) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(40)) + .clickable { haptic.performHapticFeedback(HapticFeedbackType.LongPress); onClick() } + .padding(vertical = 6.dp, horizontal = lerp(10.dp, 12.dp, shrinkFactor)), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = Color.White, + modifier = Modifier.size(20.dp).graphicsLayer { + val s = 0.82f + (0.18f * shrinkFactor); scaleX = s; scaleY = s + } + ) + if (label != null) { + val labelAlpha = (shrinkFactor - 0.1f).coerceIn(0f, 1f) + Spacer(modifier = Modifier.width((8 * shrinkFactor).dp)) + Text( + text = label, + color = Color.White.copy(alpha = labelAlpha), + fontSize = 12.sp, fontWeight = FontWeight.Medium, + maxLines = 1, overflow = TextOverflow.Clip, + modifier = Modifier + .graphicsLayer { alpha = labelAlpha; translationX = (-4 * (1f - shrinkFactor)).dp.toPx() } + .widthIn(max = (75 * shrinkFactor).dp) + ) + } + } + } + } + + @Composable + fun RowScope.AphelionHomeActionChips( + scrollState: androidx.compose.foundation.ScrollState, + haptic: HapticFeedback + ) { + val shrinkFactor by remember(scrollState.value) { + derivedStateOf { (1f - (scrollState.value.toFloat() / Motion.HEADER_MORPH_THRESHOLD)).coerceIn(0f, 1f) } + } + AphelionTopBarActionChip( + icon = Icons.Filled.BugReport, + label = context.translation["manager.routes.home_logs"], + shrinkFactor = shrinkFactor, haptic = haptic + ) { routes.homeLogs.navigate() } + AphelionTopBarActionChip( + icon = Icons.Filled.Settings, + label = context.translation["manager.routes.home_settings"], + shrinkFactor = shrinkFactor, haptic = haptic + ) { routes.settings.navigate() } + } + + @OptIn(ExperimentalLayoutApi::class) + @Composable + fun AphelionHeroSection( + versionName: String, + latestUpdate: Updater.LatestRelease?, + downloadState: UpdateDownloader.DownloadState, + downloadProgress: Float, + onUpdateAction: () -> Unit, + channelLabel: String, + isPurrAuraActive: Boolean, + onAboutClick: () -> Unit, + avenirNext: FontFamily, + scrollOffset: () -> Int, + haptic: HapticFeedback + ) { + val heroShape = RoundedCornerShape(36.dp) + val gitHashShort = remember { (context.installationSummary.modInfo?.gitHash ?: BuildConfig.GIT_HASH).take(7) } + Box( + modifier = Modifier + .padding(horizontal = HomeRootSection.cardMargin, vertical = 6.dp) + .clip(heroShape) + .background(Brush.linearGradient(heroGradientColors)) + .border(1.dp, Color.White.copy(alpha = 0.1f), heroShape) + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 22.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Column( + verticalArrangement = Arrangement.spacedBy(6.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "PurrfectSnap", + color = Color.White, fontSize = 34.sp, fontWeight = FontWeight.ExtraBold, fontFamily = avenirNext, + modifier = Modifier.graphicsLayer { + alpha = (1f - ((scrollOffset() - 250f) / 120f)).coerceIn(0f, 1f) + translationY = (-scrollOffset() * 0.06f) + } + ) + Text( + text = "By \u039eT\u039eRNAL", + color = Color.White.copy(alpha = 0.75f), fontSize = 14.sp, fontFamily = avenirNext, + modifier = Modifier.graphicsLayer { + alpha = (1f - ((scrollOffset() - 300f) / 120f)).coerceIn(0f, 1f) + translationY = (-scrollOffset() * 0.04f) + } + ) + Text( + text = translation["hero_tagline"] ?: "", + color = Color.White.copy(alpha = 0.9f), fontSize = 15.sp, lineHeight = 20.sp, textAlign = TextAlign.Center, + modifier = Modifier.graphicsLayer { + alpha = (1f - ((scrollOffset() - 350f) / 120f)).coerceIn(0f, 1f) + translationY = (-scrollOffset() * 0.02f) + } + ) + } + FlowRow( + modifier = Modifier.fillMaxWidth().graphicsLayer { + 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)) + } + } + + 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)) + ) { + Row( + 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) + } + 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)) } + } + 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) + } + } + 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) + } + } + } + } + } + } + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(24.dp), + color = Color.White.copy(alpha = 0.08f), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.10f)), + tonalElevation = 0.dp, shadowElevation = 0.dp + ) { + val unifiedButtonWidth = 180.dp + Column( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + 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), + tonalElevation = 0.dp, shadowElevation = 0.dp + ) { + Row( + modifier = Modifier.fillMaxSize().padding(horizontal = 14.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Box(modifier = Modifier.size(20.dp), contentAlignment = Alignment.Center) { + LivingPurrAura(isActive = isPurrAuraActive, haptic = haptic) + } + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = if (isPurrAuraActive) translation["purr_aura_active_label"] ?: "" else translation["purr_aura_inactive_label"] ?: "", + color = Color.White, fontWeight = FontWeight.Bold, fontSize = 13.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) + ) { + 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) + } + } + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(26.dp), + color = Color.White.copy(alpha = 0.06f), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.10f)) + ) { + Row( + 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) + ) { + 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)) + } + } + 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"]) }, + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.35f)), + colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White), + contentPadding = PaddingValues(horizontal = 12.dp) + ) { + 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) + } + } + 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 + ) + } + } + } + } + } + + val haptic = LocalHapticFeedback.current + 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 hasInitialized = prefs.getBoolean(HomeRootSection.QUICK_TILES_INITIALIZED_PREF, false) + when { + storedTiles.isNotEmpty() -> { + if (!hasInitialized) prefs.edit().putBoolean(HomeRootSection.QUICK_TILES_INITIALIZED_PREF, true).apply() + storedTiles + } + hasInitialized -> storedTiles + else -> { + context.database.setQuickTiles(allQuickTileNames) + prefs.edit().putBoolean(HomeRootSection.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 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 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) { + 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) + } + } else { + abiName?.let { arch -> latest.assetDownloads[arch] }?.let { url -> + UpdateDownloader.downloadAndInstall(context, url, url.substringAfterLast('/'), coroutineScope) + } + } + } + } + + fun loadAnnouncements() { + if (announcementsText != null) return + announcementsLoading = true + 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(HomeRootSection.pageBackgroundGradient)) { + + 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() + val headerHeight = lerp(54.dp, 56.dp, focusFactor) + val containerTopPadding = lerp(statusBarHeight + 2.dp, 0.dp, focusFactor) + val internalTopPadding = lerp(0.dp, statusBarHeight, focusFactor) + val topCorners = lerp(26.dp, 0.dp, focusFactor) + val bottomCorners = lerp(26.dp, 28.dp, focusFactor) + + Box(modifier = Modifier.fillMaxWidth().zIndex(10f)) { + val refractiveColor = remember { Color(0xFF241F52) } + Box( + modifier = Modifier + .fillMaxWidth() + .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 + ) + ) + ) + + Surface( + modifier = Modifier + .fillMaxWidth() + .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) + .height(headerHeight) + ) { + Text( + text = "PurrfectSnap", + color = Color.White.copy(alpha = stickyBrandingAlpha), + fontSize = 18.sp, fontWeight = FontWeight.Bold, fontFamily = avenirNext, + modifier = Modifier.align(Alignment.Center) + ) + val announcementShift by remember(focusFactor) { derivedStateOf { (-6 * focusFactor).dp } } + Row( + modifier = Modifier.align(Alignment.CenterStart).graphicsLayer { translationX = announcementShift.toPx() }, + verticalAlignment = Alignment.CenterVertically + ) { + AphelionTopBarActionChip( + icon = Icons.Filled.Notifications, label = null, + shrinkFactor = (1f - focusFactor).coerceIn(0f, 1f), + contentDescription = translation["announcements_button_description"], + haptic = haptic + ) { showAnnouncementsDialog = true; loadAnnouncements() } + } + val settingsShift by remember(focusFactor) { derivedStateOf { (6 * focusFactor).dp } } + Row( + modifier = Modifier.align(Alignment.CenterEnd).graphicsLayer { translationX = settingsShift.toPx() }, + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AphelionHomeActionChips(scrollState = scrollState, haptic = haptic) + } + } + } + } + + Column(modifier = Modifier.fillMaxSize().verticalScroll(scrollState).padding(bottom = routes.bottomPadding + 4.dp)) { + Spacer(Modifier.height(controlsHeight + containerTopPadding)) + + AphelionHeroSection( + versionName = BuildConfig.VERSION_NAME, + latestUpdate = latestUpdate, + downloadState = downloadState, + downloadProgress = downloadProgress, + onUpdateAction = { latestUpdate?.let { showChangelogDialog = true } }, + channelLabel = channelLabel, + isPurrAuraActive = isPurrAuraActive, + onAboutClick = { routes.about.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 = HomeRootSection.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 + ) { + 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"] ?: "") + } + } 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); animationPhase = 2 + delay(1200); animationPhase = 3 + } + } + + BoxWithConstraints( + modifier = Modifier.fillMaxWidth().onGloballyPositioned { coords -> + val windowHeight = context.androidContext.resources.displayMetrics.heightPixels + val posY = coords.localToWindow(Offset.Zero).y + if (posY > 0 && posY < windowHeight * 0.95f) gridIsVisible = true + } + ) { + 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.forEach { name -> + val cardEntry = cards.entries.find { it.key.first == name } ?: return@forEach + 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" + ) + Surface( + modifier = Modifier.width(100.dp).aspectRatio(1.05f).scaleOnPress(interactionSource) + .clickable { haptic.performHapticFeedback(HapticFeedbackType.LongPress); cardEntry.value(routes) }, + shape = RoundedCornerShape(18.dp), + color = Color.White.copy(alpha = 0.06f), + 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)) + PurrfectMarqueeText( + text = cardEntry.key.first, + style = TextStyle(fontSize = 12.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center), + color = Color.White, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } + } + } + } + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + } + } + + 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 }, + 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"] ?: "", color = PurrfectPalette.textPrimary, fontSize = 14.sp) + } + } + ) + } + + if (showChangelogDialog) { + AestheticDialog( + onDismissRequest = { showChangelogDialog = false }, + title = translation["changelog_dialog_title"] ?: "", + text = 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 = { newList -> + val removed = selectedTiles.filter { it !in newList } + removed.forEach { clearTileSpan(it); clearTileOffset(it) } + selectedTiles.clear(); selectedTiles.addAll(newList) + context.coroutineScope.launch { context.database.setQuickTiles(selectedTiles) } + showQuickActionsMenu = false + }, + translation = translation + ) + } +} diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionLogsView.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionLogsView.kt new file mode 100644 index 00000000..1a8f5cd2 --- /dev/null +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionLogsView.kt @@ -0,0 +1,179 @@ +package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DeleteSweep +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import androidx.navigation.NavBackStackEntry +import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar +import me.eternal.purrfectsnap.ui.manager.pages.home.HomeLogs +import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette +import me.eternal.purrfectsnap.ui.util.headerHeightTracker +import me.eternal.purrfectsnap.ui.util.Motion +import kotlinx.coroutines.launch +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Composable +fun HomeLogs.AphelionLogsScreen(nav: NavBackStackEntry) { + val coroutineScope = rememberCoroutineScope() + var controlsHeight by remember { mutableStateOf(100.dp) } + val composeContext = LocalContext.current + var logReader by remember { mutableStateOf(null) } + val visibleLogs = remember { mutableStateListOf() } + var isRefreshing by remember { mutableStateOf(false) } + + fun refreshLogs() { + isRefreshing = true + coroutineScope.launch(Dispatchers.IO) { + val readerResult = runCatching { + context.log.newReader { line -> + if (shouldHideLog(line)) return@newReader + coroutineScope.launch(Dispatchers.Main) { + visibleLogs.add(line) + } + } + } + readerResult.onFailure { + context.longToast(translation["read_logs_failed_toast"] ?: "Failed to read logs") + } + readerResult.getOrNull()?.let { reader -> + logReader = reader + val filteredLogs = (0 until reader.lineCount).mapNotNull { index -> + reader.getLogLine(index)?.takeUnless(::shouldHideLog) + } + withContext(Dispatchers.Main) { + visibleLogs.clear() + visibleLogs.addAll(filteredLogs) + if (visibleLogs.isNotEmpty()) { + logListState.scrollToItem((visibleLogs.size - 1).coerceAtLeast(0)) + } + isRefreshing = false + } + } + } + } + + LaunchedEffect(externalRefreshTick.value) { + if (externalRefreshTick.value > 0) { + refreshLogs() + } + } + + LaunchedEffect(Unit) { + refreshLogs() + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(PurrfectPalette.backgroundGradient) + ) { + var showDropDown by remember { mutableStateOf(false) } + + Surface( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 12.dp), + shape = RoundedCornerShape(24.dp), + color = Color.White.copy(alpha = 0.04f), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)) + ) { + if (visibleLogs.isEmpty() && logReader != null) { + EmptyLogsState() + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize(), + state = logListState, + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues( + start = 8.dp, + end = 8.dp, + top = controlsHeight, + bottom = routes.bottomPadding + 12.dp + ) + ) { + items(visibleLogs, key = { it.hashCode() }) { line -> + LogEntryCard(line = line, composeContext = composeContext) + } + } + } + } + + FloatingTopBar( + title = context.translation["manager.routes.home_logs"] ?: "Logs", + onBack = { routes.navController.popBackStack() }, + scrollOffset = if (logListState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else logListState.firstVisibleItemScrollOffset, + modifier = Modifier.headerHeightTracker { controlsHeight = it }, + actions = { + if (isRefreshing) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = Color.White + ) + } + IconButton(onClick = { 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"] ?: "Clear", 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"] ?: "Export", color = Color.White) }, + colors = MenuDefaults.itemColors( + textColor = Color.White, + leadingIconColor = PurrfectPalette.glowSecondary + ) + ) + } + } + } + ) + } +} diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionScriptingView.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionScriptingView.kt new file mode 100644 index 00000000..db831887 --- /dev/null +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionScriptingView.kt @@ -0,0 +1,92 @@ +package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.navigation.NavBackStackEntry +import androidx.documentfile.provider.DocumentFile +import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState +import me.eternal.purrfectsnap.ui.manager.pages.scripting.ScriptingRootSection +import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette +import me.eternal.purrfectsnap.common.util.ktx.openLink +import kotlinx.coroutines.launch + +@Composable +fun ScriptingRootSection.AphelionScriptingScreen(nav: NavBackStackEntry) { + val scriptingFolder by rememberAsyncMutableState( + defaultValue = null, + updateDispatcher = reloadDispatcher + ) { context.scriptManager.getScriptsFolder() } + val tabTitles = listOf(translation["installed_scripts_tab"], translation["catalog_tab"]) + var showImportDialog by remember { mutableStateOf(false) } + var showToast by remember { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(scriptingFolder) { + if (scriptingFolder == null && selectedTab != 0) { + selectedTab = 0 + } + } + + if (showImportDialog) { + ImportRemoteScript { showImportDialog = false } + } + if (showToast) { + LaunchedEffect(showToast) { + context.shortToast(translation["select_scripts_folder_toast"]) + showToast = false + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(PurrfectPalette.backgroundGradient) + ) { + ScriptingHeader( + titles = tabTitles, + selectedTab = selectedTab, + onTabSelected = { index -> + if (index == 1 && scriptingFolder == null) { + showToast = true + } else { + selectedTab = index + } + }, + onImport = { + if (scriptingFolder == null) showToast = true else showImportDialog = true + }, + onOpenFolder = { + if (scriptingFolder == null) { + showToast = true + } else { + scriptingFolder?.let { + context.androidContext.openLink( + it.uri.toString(), + context.translation["toast_open_link_failed"] + ) + } + } + }, + onManageRepos = { routes.manageScriptRepos.navigate() }, + onDocs = { + context.androidContext.openLink( + "https://github.com/SnapEnhance/scripting-docs", + context.translation["toast_open_link_failed"] + ) + }, + folderSelected = scriptingFolder != null + ) + Spacer(Modifier.height(12.dp)) + when (selectedTab) { + 0 -> InstalledTabContent( + scriptingFolder = scriptingFolder + ) + 1 -> CatalogTabContent( + scriptingFolder = scriptingFolder + ) + } + } +} diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionSettingsView.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionSettingsView.kt new file mode 100644 index 00000000..49687f0a --- /dev/null +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionSettingsView.kt @@ -0,0 +1,292 @@ +package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion + +import android.content.SharedPreferences +import android.content.Intent +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +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.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.core.content.edit +import androidx.core.net.toUri +import androidx.navigation.NavBackStackEntry +import kotlinx.coroutines.launch +import me.eternal.purrfectsnap.R +import me.eternal.purrfectsnap.common.action.EnumAction +import me.eternal.purrfectsnap.common.bridge.InternalFileHandleType +import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState +import me.eternal.purrfectsnap.storage.getAllScopeNotes +import me.eternal.purrfectsnap.storage.setAllScopeNotes +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.manager.pages.home.HomeSettings +import me.eternal.purrfectsnap.ui.util.headerHeightTracker +import me.eternal.purrfectsnap.ui.setup.Requirements +import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors +import me.eternal.purrfectsnap.ui.util.saveFile +import me.eternal.purrfectsnap.ui.util.openFile +import java.io.File +import java.net.URLEncoder + +@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) +@Composable +fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) { + val scope = rememberCoroutineScope() + val scrollState = rememberScrollState() + val hapticFeedback = LocalHapticFeedback.current + var controlsHeight by remember { mutableStateOf(100.dp) } + var showResetSetupDialog by remember { mutableStateOf(false) } + + val sharedButtonColors = ButtonDefaults.buttonColors( + containerColor = Color.White.copy(alpha = 0.07f), + contentColor = Color.White + ) + val sharedOutlinedColors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White) + + LaunchedEffect(scrollState.value) { + routes.navigation?.globalScrollOffset = scrollState.value + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(PurrfectPalette.backgroundGradient) + ) { + if (showResetSetupDialog) { + AestheticDialog( + onDismissRequest = { showResetSetupDialog = false }, + title = translation["reset_setup_dialog_title"], + text = translation["reset_setup_dialog_text"], + icon = Icons.Filled.Warning, + confirmButtonText = context.translation["button.positive"], + dismissButtonText = context.translation["button.negative"], + 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 = Intent(context.androidContext, me.eternal.purrfectsnap.ui.setup.SetupActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + context.androidContext.startActivity(intent) + routes.navController.popBackStack() + }, + onDismiss = { showResetSetupDialog = false }, + showCloseButton = false + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(top = controlsHeight, bottom = routes.bottomPadding + 24.dp) + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // THEME SWITCHER + GlassCard { + RowTitle(title = translation["ui_theme_title"] ?: "UI Theme") + ShiftedRow { + Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { + Text(text = translation["settings_ui_theme"] ?: "Aphelion Theme", fontSize = 14.sp) + val currentThemeId = context.config.root.global.uiSettings.managerTheme.get() + Switch( + checked = currentThemeId == "APHELION", + onCheckedChange = { isAphelion -> + if (context.config.root.global.uiSettings.hapticFeedback.get()) { hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) } + val newId = if (isAphelion) "APHELION" else "LEGACY" + context.config.root.global.uiSettings.managerTheme.set(newId) + context.config.writeConfig() + }, + modifier = Modifier.padding(end = 26.dp), + colors = purrfectSwitchColors() + ) + } + } + } + + // ACTIONS + GlassCard { + RowTitle(title = translation["actions_title"]) + EnumAction.entries.forEach { enumAction -> RowAction(key = enumAction.key) { context.launchActionIntent(enumAction) } } + RowAction(key = "regen_mappings") { context.checkForRequirements(Requirements.MAPPINGS) } + RowAction(key = "change_language") { context.checkForRequirements(Requirements.LANGUAGE) } + } + + // UI SETTINGS + GlassCard { + RowTitle(title = translation["ui_settings_title"]) + ShiftedRow { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { + Text(text = translation["haptic_feedback_label"], fontSize = 14.sp) + var hapticEnabled by remember { mutableStateOf(context.config.root.global.uiSettings.hapticFeedback.getNullable() ?: true) } + Switch(checked = hapticEnabled, onCheckedChange = { if (it) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); hapticEnabled = 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"], fontSize = 14.sp) + 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()) + } + } + } + } + + // UPDATES + GlassCard { + RowTitle(title = translation["updates_title"]) + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + var autoUpdateCheck by remember { mutableStateOf(context.config.root.global.updateSettings.autoUpdateCheck.getNullable() ?: true) } + var selectedChannel by remember { mutableStateOf(context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable") } + var channelMenuExpanded by remember { mutableStateOf(false) } + ShiftedRow { + Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { + Text(text = translation["auto_update_check"], fontSize = 14.sp) + 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) { + ExposedDropdownMenuBox(expanded = channelMenuExpanded, onExpandedChange = { channelMenuExpanded = it }, modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp)) { + AestheticDropdownField(value = translation.getOrNull("update_channel_${selectedChannel}") ?: selectedChannel, expanded = channelMenuExpanded, modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), onClick = { channelMenuExpanded = true }) + ExposedDropdownMenu(expanded = channelMenuExpanded, onDismissRequest = { channelMenuExpanded = false }) { + listOf("stable", "prerelease").forEach { channel -> DropdownMenuItem(text = { Text(text = translation.getOrNull("update_channel_${channel}") ?: channel) }, onClick = { selectedChannel = channel; channelMenuExpanded = false; context.config.root.global.updateSettings.updateChannel.set(channel); context.config.writeConfig(); scheduleUpdateCheck() }) } + } + } + } + } + } + + // RESET SETUP + GlassCard { + RowTitle(title = translation["reset_setup_title"]) + ShiftedRow(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp).clickable { showResetSetupDialog = true }, horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text(text = translation["reset_setup_action"], fontSize = 16.sp, fontWeight = FontWeight.Medium, lineHeight = 20.sp) + Icon(imageVector = Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null, modifier = Modifier.padding(end = 14.dp)) + } + } + + // MESSAGE LOGGER + GlassCard { + RowTitle(title = translation["message_logger_title"]) + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + var storedMessagesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredMessageCount() } + var storedStoriesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredStoriesCount() } + var showImportDialog by remember { mutableStateOf(false) } + Column(modifier = Modifier.fillMaxWidth().padding(5.dp), verticalArrangement = Arrangement.spacedBy(10.dp), horizontalAlignment = Alignment.CenterHorizontally) { + val summary = translation.format("message_logger_summary", "messageCount" to storedMessagesCount.toString(), "storyCount" to storedStoriesCount.toString()).replace("\n", " | ") + Text(summary, maxLines = 2, color = Color.White, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) + FlowRow(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(10.dp) ) { + Button(onClick = { runCatching { activityLauncherHelper.saveFile("message_logger.db", "application/octet-stream") { uri -> context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { out -> context.messageLogger.databaseFile.inputStream().use { it.copyTo(out) } } } }.onFailure { context.log.error("Failed to export", it) } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["export_button"]) } + Button(onClick = { runCatching { activityLauncherHelper.openFile("application/octet-stream") { uri -> val tempFile = File(context.androidContext.cacheDir, "view_logger.db"); context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { it.copyTo(tempFile.outputStream()) }; routes.viewLoggerHistory.navigate { put("uri", URLEncoder.encode(tempFile.toUri().toString(), "UTF-8")) } } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["view_button"]) } + Button(onClick = { runCatching { context.messageLogger.purgeAll(); storedMessagesCount = 0; storedStoriesCount = 0 }.onSuccess { context.shortToast(translation["success_toast"]) } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["clear_button"]) } + Button(onClick = { showImportDialog = true }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["import_button"]) } + } + } + OutlinedButton(modifier = Modifier.fillMaxWidth().padding(5.dp), onClick = { routes.loggerHistory.navigate() }, colors = sharedOutlinedColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.2f))) { Text(translation["view_logger_history_button"]) } + if (showImportDialog) { + AestheticDialog(onDismissRequest = { showImportDialog = false }, title = translation["message_logger_import_title"], text = translation["message_logger_import_text"], icon = Icons.Filled.Info, confirmButtonText = context.translation["button.import"], dismissButtonText = context.translation["button.cancel"], onConfirm = { showImportDialog = false; runCatching { activityLauncherHelper.openFile("application/octet-stream") { uri -> context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { context.messageLogger.databaseFile.outputStream().use { out -> it.copyTo(out) } }; storedMessagesCount = context.messageLogger.getStoredMessageCount(); storedStoriesCount = context.messageLogger.getStoredStoriesCount(); context.shortToast(translation["success_toast"]) } } }, onDismiss = { showImportDialog = false }, showCloseButton = false) + } + } + } + + // FRIEND NOTES + GlassCard { + RowTitle(title = translation["friend_notes_title"]) + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(text = translation["friend_notes_description"], modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp), color = Color.White, textAlign = TextAlign.Center) + Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Button(onClick = { runCatching { val notes = context.database.getAllScopeNotes(); if (notes.isEmpty()) return@runCatching; val json = context.gson.toJson(notes); activityLauncherHelper.saveFile("notes.json", "application/json") { uri -> context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { it.write(json.toByteArray()) }; context.shortToast(translation["friend_notes_backup_success"]) } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["backup_button"]) } + Button(onClick = { runCatching { activityLauncherHelper.openFile("application/json") { uri -> context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { val json = it.reader().readText(); val notes = context.gson.fromJson>(json, object : com.google.gson.reflect.TypeToken>() {}.type); context.database.setAllScopeNotes(notes); context.shortToast(translation["friend_notes_restore_success"]) } } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["restore_button"]) } + } + } + } + } + + // DEBUG + GlassCard { + RowTitle(title = translation["debug_title"]) + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp)) { + var selectedFileType by remember { mutableStateOf(InternalFileHandleType.entries.first()) } + var expanded by remember { mutableStateOf(false) } + Box(modifier = Modifier.weight(1f)) { + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }, modifier = Modifier.fillMaxWidth()) { + AestheticDropdownField(value = translation.getOrNull("debug_file_${selectedFileType.name.lowercase()}") ?: selectedFileType.fileName, expanded = expanded, modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), onClick = { expanded = true }) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + InternalFileHandleType.entries.forEach { fileType -> DropdownMenuItem(onClick = { expanded = false; selectedFileType = fileType }, text = { Text(text = translation.getOrNull("debug_file_${fileType.name.lowercase()}") ?: fileType.fileName) }) } + } + } + } + Button(onClick = { runCatching { scope.launch { selectedFileType.resolve(context.androidContext).delete() } }.onSuccess { context.shortToast(translation["success_toast"]) } }, colors = ButtonDefaults.buttonColors(containerColor = Color.White.copy(alpha = 0.1f), contentColor = Color.White), 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)) + Spacer(modifier = Modifier.width(8.dp)); Text(translation["clear_button"]) + } + } + ShiftedRow { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + PremiumPreferenceToggle(context.sharedPreferences, key = "test_mode", text = translation["test_mode_label"], defaultValue = true, confirmDisableTitle = translation["purr_aura_disable_title"], confirmDisableText = translation["purr_aura_disable_text"]) + PreferenceToggle(context.sharedPreferences, key = "disable_feature_loading", text = translation["disable_feature_loading_label"]) + PreferenceToggle(context.sharedPreferences, key = "disable_mapper", text = translation["disable_auto_mapper_label"]) + PreferenceToggle(context.sharedPreferences, key = "disable_bypass_indicator", text = translation["disable_bypass_indicator_label"]) + } + } + } + } + } + } + + me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar( + title = routeInfo.translatedKey?.value ?: translation["manager.routes.home_settings"] ?: "Settings", + onBack = { routes.navController.popBackStack() }, + scrollOffset = scrollState.value, + titleAlignment = Alignment.CenterHorizontally, + modifier = Modifier.headerHeightTracker { controlsHeight = it }, + actions = { + IconButton(onClick = { + if (context.config.root.global.uiSettings.hapticFeedback.get()) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + routes.navigation?.openBottomBarCustomization = true + }) { + Icon( + imageVector = Icons.Filled.Tune, + contentDescription = null, + tint = Color.White.copy(alpha = 0.85f) + ) + } + } + ) + } +} diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionSocialView.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionSocialView.kt new file mode 100644 index 00000000..1cbdf70a --- /dev/null +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionSocialView.kt @@ -0,0 +1,176 @@ +package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion + +import androidx.compose.animation.* +import androidx.compose.animation.core.* +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +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.items +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.foundation.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.People +import androidx.compose.material.icons.filled.RemoveRedEye +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.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.sp +import androidx.navigation.NavBackStackEntry +import kotlinx.coroutines.launch +import me.eternal.purrfectsnap.R +import me.eternal.purrfectsnap.common.data.SocialScope +import me.eternal.purrfectsnap.ui.manager.pages.social.SocialRootSection +import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun SocialRootSection.AphelionSocialScreen(nav: NavBackStackEntry) { + val titles = remember { + listOf(translation["friends_tab"], translation["groups_tab"]) + } + val coroutineScope = rememberCoroutineScope() + val pagerState = rememberPagerState { titles.size } + var searchQuery by rememberSaveable { mutableStateOf("") } + var searchActive by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(Unit) { + updateScopeLists() + } + val normalizedQuery = remember(searchQuery) { searchQuery.trim() } + val filteredFriends = remember(friendList, normalizedQuery) { + if (normalizedQuery.isBlank()) { + friendList + } else { + friendList.filter { + it.mutableUsername.contains(normalizedQuery, ignoreCase = true) || + it.displayName?.contains(normalizedQuery, ignoreCase = true) == true + } + } + } + val filteredGroups = remember(groupList, normalizedQuery) { + if (normalizedQuery.isBlank()) { + groupList + } else { + groupList.filter { it.name.contains(normalizedQuery, ignoreCase = true) } + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(PurrfectPalette.backgroundGradient) + ) { + SocialHeader( + titles = titles, + pagerState = pagerState, + onTabSelected = { index -> + coroutineScope.launch { pagerState.animateScrollToPage(index) } + }, + friendCount = friendList.size, + groupCount = groupList.size, + searchActive = searchActive, + onSearchToggle = { + searchActive = !searchActive + if (!searchActive) searchQuery = "" + } + ) + if (searchActive) { + val searchHint = context.translation["manager.dialogs.add_friend.search_hint"] ?: "Search" + val searchShape = RoundedCornerShape(18.dp) + val searchBorder = Brush.linearGradient( + listOf( + PurrfectPalette.glowPrimary.copy(alpha = 0.45f), + PurrfectPalette.glowSecondary.copy(alpha = 0.35f) + ) + ) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 6.dp), + shape = searchShape, + color = Color.White.copy(alpha = 0.05f), + border = BorderStroke(1.dp, searchBorder), + tonalElevation = 0.dp, + shadowElevation = 0.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(PurrfectPalette.cardOverlay, searchShape) + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = searchHint, + tint = PurrfectPalette.textSecondary + ) + BasicTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + singleLine = true, + textStyle = MaterialTheme.typography.bodyMedium.copy( + color = Color.White, + fontSize = 15.sp + ), + cursorBrush = SolidColor(PurrfectPalette.glowSecondary), + modifier = Modifier.weight(1f) + ) { innerTextField -> + if (searchQuery.isEmpty()) { + Text( + text = searchHint, + color = PurrfectPalette.textSecondary, + fontSize = 14.sp + ) + } + innerTextField() + } + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { searchQuery = "" }) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = translation["clear_search_button_description"] ?: "Clear", + tint = Color.White + ) + } + } + } + } + } + Spacer(Modifier.height(12.dp)) + HorizontalPager( + modifier = Modifier + .fillMaxSize(), + state = pagerState + ) { page -> + when (page) { + 0 -> ScopeList(SocialScope.FRIEND, filteredFriends, filteredGroups) + 1 -> ScopeList(SocialScope.GROUP, filteredFriends, filteredGroups) + } + } + } +} diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionTasksView.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionTasksView.kt new file mode 100644 index 00000000..258773d3 --- /dev/null +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionTasksView.kt @@ -0,0 +1,547 @@ +package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion + +import android.content.Intent +import android.graphics.drawable.ColorDrawable +import androidx.compose.animation.* +import androidx.compose.animation.core.* +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.gestures.detectTapGestures +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +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.rotate +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +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 androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.Lifecycle +import androidx.navigation.NavBackStackEntry +import coil.compose.rememberAsyncImagePainter +import coil.request.ImageRequest +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import me.eternal.purrfectsnap.common.ui.TopBarActionButton +import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState +import me.eternal.purrfectsnap.task.* +import me.eternal.purrfectsnap.ui.manager.Routes +import me.eternal.purrfectsnap.ui.manager.pages.TasksRootSection +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 me.eternal.purrfectsnap.ui.util.headerHeightTracker +import me.eternal.purrfectsnap.ui.util.Motion + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) { + val scrollState = rememberLazyListState() + val haptic = LocalHapticFeedback.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() + var showConfirmDialog by remember { mutableStateOf(false) } + var alsoDeleteFiles by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + fetchActiveTasks(this) + } + + DisposableEffect(Unit) { + onDispose { + taskSelection.clear() + } + } + + OnLifecycleEvent { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + fetchActiveTasks(scope) + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .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() + ) + } 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 = routes.navigation?.globalScrollOffset ?: 0, + modifier = Modifier.headerHeightTracker { controlsHeight = it }, + actions = { + Surface( + shape = RoundedCornerShape(18.dp), + color = Color.White.copy(alpha = 0.08f), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f)) + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon(Icons.Filled.PlaylistAddCheckCircle, contentDescription = null, tint = Color.White) + Text( + text = translation.format("running_count", "count" to activeTasks.size.toString()), + color = Color.White, + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp + ) + } + } + Spacer(Modifier.width(8.dp)) + 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(HapticFeedbackType.LongPress) + mergeSelection( + taskSelection.toList().also { taskSelection.clear() } + .map { it.first to it.second!! } + ) + } + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Filled.Merge, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(6.dp)) + Text( + translation["merge_button"] ?: "Merge", + color = Color.White, + fontSize = 12.sp, + fontWeight = FontWeight.Bold + ) + } + } + } + IconButton(onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + showConfirmDialog = true + }) { + 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) + ) { + item { + if (activeTasks.isEmpty() && recentTasks.isEmpty()) { + AphelionTasksEmptyState(text = translation["no_tasks"] ?: "No tasks") + } + } + items(activeTasks, key = { it.taskId }) { pendingTask -> + AphelionTaskCard(modifier = Modifier.fillMaxWidth(), pendingTask.task, pendingTask = pendingTask) + } + items(recentTasks, key = { it.hash }) { task -> + AphelionTaskCard(modifier = Modifier.fillMaxWidth(), task) + } + item { + Spacer(modifier = Modifier.height(40.dp)) + LaunchedEffect(remember { derivedStateOf { scrollState.firstVisibleItemIndex } }) { + fetchNewRecentTasks() + } + } + } + } + } + + if (showConfirmDialog) { + 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, scope) + }, + onDismiss = { showConfirmDialog = false } + ) + } +} + +@Composable +internal fun TasksRootSection.AphelionTasksEmptyState(text: String) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 60.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = 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.12f)) + ) { + Box( + modifier = Modifier + .size(58.dp) + .background( + Brush.linearGradient( + listOf( + PurrfectPalette.glowPrimary.copy(alpha = 0.32f), + PurrfectPalette.glowSecondary.copy(alpha = 0.28f) + ) + ), + CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Filled.CheckCircle, + contentDescription = text, + tint = Color.White + ) + } + } + Text( + text = text, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.ExtraBold), + color = Color.White + ) + } +} + +@Composable +internal fun TasksRootSection.AphelionTaskCard(modifier: Modifier, task: Task, pendingTask: PendingTask? = null) { + var taskStatus by remember { mutableStateOf(task.status) } + var taskProgressLabel by remember { mutableStateOf(null) } + var taskProgress by remember { mutableIntStateOf(-1) } + val isSelected by remember { derivedStateOf { taskSelection.any { it.first == task } } } + + var documentFileMimeType by remember { mutableStateOf("") } + var isDocumentFileReadable by remember { mutableStateOf(true) } + val documentFile by rememberAsyncMutableState( + defaultValue = null as DocumentFile?, + keys = arrayOf(taskStatus.key) + ) { + DocumentFile.fromSingleUri(context.androidContext, task.extra?.toUri() ?: return@rememberAsyncMutableState null)?.apply { + documentFileMimeType = type ?: "" + isDocumentFileReadable = canRead() + } + } + + val listener = remember { PendingTaskListener( + onStateChange = { taskStatus = it }, + onProgress = { label, progress -> + taskProgressLabel = label + taskProgress = progress + } + ) } + + LaunchedEffect(Unit) { pendingTask?.addListener(listener) } + DisposableEffect(Unit) { onDispose { pendingTask?.removeListener(listener) } } + + val haptic = LocalHapticFeedback.current + val isActive = pendingTask != null && !taskStatus.isFinalStage() + + fun toggleSelection() { + if (isSelected) { + taskSelection.removeIf { it.first == task } + return + } + taskSelection.add(task to documentFile) + } + + fun openFile() { + if (!isDocumentFileReadable || documentFile == null) return + runCatching { + context.androidContext.startActivity(Intent(Intent.ACTION_VIEW).apply { + setDataAndType(documentFile!!.uri, documentFile!!.type) + flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK + }) + }.onFailure { + context.log.error("Failed to open file ${documentFile?.uri}", it) + context.shortToast(translation["failed_to_open_file"] ?: "Failed to open file") + } + } + + val cardModifier = modifier + .pointerInput(Unit) { + detectTapGestures( + onTap = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + if (taskSelection.isNotEmpty()) { + toggleSelection() + return@detectTapGestures + } + openFile() + }, + onLongPress = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + if (taskSelection.isNotEmpty()) { + openFile() + return@detectTapGestures + } + toggleSelection() + } + ) + } + .let { + if (isSelected) { + it.border(2.dp, PurrfectPalette.glowSecondary, RoundedCornerShape(22.dp)).clip(RoundedCornerShape(22.dp)) + } else it + } + + val chipLabel = when { + isActive -> translation.getOrNull("task_sending") ?: "Sending" + taskStatus == TaskStatus.SUCCESS -> null + taskStatus == TaskStatus.FAILURE -> translation.getOrNull("task_failed") ?: "Failed" + taskStatus == TaskStatus.CANCELLED -> translation.getOrNull("task_cancelled") ?: "Cancelled" + else -> taskStatus.name.lowercase().replaceFirstChar { it.titlecase() } + } + val chipIcon = when { + isActive -> null + taskStatus == TaskStatus.SUCCESS -> Icons.Filled.Check + taskStatus == TaskStatus.FAILURE -> Icons.Filled.WarningAmber + taskStatus == TaskStatus.CANCELLED -> Icons.Filled.Cancel + else -> Icons.Filled.Info + } + val chipColors = when { + isActive -> AssistChipDefaults.assistChipColors( + containerColor = Color.White.copy(alpha = 0.08f), + labelColor = Color.White + ) + taskStatus == TaskStatus.SUCCESS -> AssistChipDefaults.assistChipColors() + taskStatus == TaskStatus.FAILURE -> AssistChipDefaults.assistChipColors( + containerColor = Color(0xFFFF6B9B).copy(alpha = 0.18f), + labelColor = Color.White + ) + taskStatus == TaskStatus.CANCELLED -> AssistChipDefaults.assistChipColors( + containerColor = Color.White.copy(alpha = 0.06f), + labelColor = PurrfectPalette.textSecondary + ) + else -> AssistChipDefaults.assistChipColors() + } + val countdownText = if (isActive) { + taskProgressLabel?.let { label -> + Regex("""(\d+d\s+)?(\d+h\s+)?(\d+m\s+)?\d+s""").find(label)?.value?.trim() ?: label + } + } else null + + val cardShape = RoundedCornerShape(22.dp) + Surface( + modifier = cardModifier, + shape = cardShape, + color = Color.Transparent, + border = BorderStroke(1.dp, if (isSelected) Brush.linearGradient(listOf(PurrfectPalette.glowPrimary, PurrfectPalette.glowSecondary)) else SolidColor(Color.White.copy(alpha = 0.1f))) + ) { + Row(modifier = Modifier.background(PurrfectPalette.cardOverlay, cardShape).padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Box(modifier = Modifier.padding(end = 15.dp).size(50.dp).clipToBounds(), contentAlignment = Alignment.Center) { + var loadFailed by remember { mutableStateOf(false) } + documentFile?.let { doc -> + if (taskStatus.isFinalStage() && isDocumentFileReadable && !loadFailed && (documentFileMimeType.contains("image") || documentFileMimeType.contains("video"))) { + val imageRequest = ImageRequest.Builder(context.androidContext) + .data(doc.uri) + .cacheKey(doc.uri.toString()) + .placeholder(ColorDrawable(PurrfectPalette.cardOverlayColor.toArgb())) + .build() + Image( + painter = rememberAsyncImagePainter( + model = imageRequest, + imageLoader = context.imageLoader, + onState = { state -> + if (state is coil.compose.AsyncImagePainter.State.Error) loadFailed = true + } + ), + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = Modifier.size(50.dp).clip(MaterialTheme.shapes.medium) + ) + } else { + when { + !isDocumentFileReadable -> Icon(Icons.Filled.DeleteOutline, contentDescription = null) + documentFileMimeType.contains("image") -> Icon(Icons.Filled.Photo, contentDescription = null) + documentFileMimeType.contains("video") -> Icon(Icons.Filled.Videocam, contentDescription = null) + documentFileMimeType.contains("audio") -> Icon(Icons.Filled.MusicNote, contentDescription = null) + else -> Icon(Icons.Filled.FileCopy, contentDescription = null) + } + } + } ?: run { + when (task.type) { + TaskType.DOWNLOAD -> Icon(Icons.Filled.Download, contentDescription = null) + TaskType.CHAT_ACTION -> Icon(Icons.Filled.ChatBubble, contentDescription = null) + TaskType.SCHEDULED_SEND -> { + val active = !taskStatus.isFinalStage() + val rotation = if (active) { + val transition = rememberInfiniteTransition(label = "scheduled_send") + transition.animateFloat(initialValue = 0f, targetValue = 360f, animationSpec = infiniteRepeatable(tween(1200, easing = LinearEasing)), label = "rotation").value + } else 0f + Box(modifier = Modifier.size(50.dp).clip(CircleShape).background(if (active) Brush.linearGradient(listOf(PurrfectPalette.glowPrimary.copy(alpha = 0.25f), PurrfectPalette.glowSecondary.copy(alpha = 0.22f))) else SolidColor(Color.White.copy(alpha = 0.06f))), contentAlignment = Alignment.Center) { + Icon(Icons.Filled.Schedule, contentDescription = null, modifier = Modifier.size(28.dp).rotate(rotation), tint = if (active) PurrfectPalette.glowSecondary else PurrfectPalette.textSecondary) + } + } + } + } + } + Column(modifier = Modifier.weight(1f)) { + if (task.type == TaskType.SCHEDULED_SEND) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(context.translation.getOrNull("scheduled_send_title") ?: "Scheduled Snaps", style = MaterialTheme.typography.labelMedium, color = PurrfectPalette.textSecondary) + Text(task.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = Color.White) + task.author?.takeIf { it != "null" }?.let { recipients -> + Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Icon(Icons.Filled.People, contentDescription = null, modifier = Modifier.size(16.dp).padding(top = 2.dp), tint = PurrfectPalette.textSecondary) + recipients.split(", ").let { list -> + Text(list.joinToString(", "), style = MaterialTheme.typography.bodyMedium, color = Color.White, lineHeight = 20.sp) + } + } + } + } + } else { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(task.title, style = MaterialTheme.typography.bodyMedium, color = Color.White) + task.author?.takeIf { it != "null" }?.let { + Spacer(modifier = Modifier.width(5.dp)) + Text(it, style = MaterialTheme.typography.bodySmall, color = PurrfectPalette.textSecondary) + } + } + Text(task.hash, style = MaterialTheme.typography.labelSmall, color = PurrfectPalette.textSecondary) + } + + Column(modifier = Modifier.padding(top = 5.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { + chipLabel?.let { label -> + val leadingIcon: (@Composable () -> Unit)? = if (isActive && task.type == TaskType.SCHEDULED_SEND) { + { Icon(Icons.Filled.Timer, contentDescription = null, modifier = Modifier.size(16.dp)) } + } else if (isActive) { + countdownText?.let { countdown -> { Text(countdown, style = MaterialTheme.typography.labelSmall) } } + } else chipIcon?.let { icon -> { Icon(icon, contentDescription = null) } } + + val displayLabel = if (isActive && task.type == TaskType.SCHEDULED_SEND && countdownText != null) { + translation.getOrNull("schedule_sending_in")?.replace("{time}", countdownText) ?: "Sending in $countdownText" + } else label + + AssistChip(onClick = {}, enabled = false, leadingIcon = leadingIcon, label = { Text(displayLabel) }, colors = chipColors) + } + + if (!taskStatus.isFinalStage()) { + if (!isActive) { + taskProgressLabel?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = Color.White) } + } + if (taskProgress != -1 && taskProgressLabel == null) { + LinearProgressIndicator( + progress = { taskProgress.toFloat() / 100f }, + strokeCap = StrokeCap.Round, modifier = Modifier.fillMaxWidth(), + color = PurrfectPalette.glowSecondary, trackColor = Color.White.copy(alpha = 0.12f) + ) + } + if (!isActive) { + task.extra?.takeIf { it.isNotEmpty() }?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = PurrfectPalette.textSecondary) } + } + } + } + } + + Column { + if (isActive) { + FilledIconButton( + onClick = { + runCatching { pendingTask?.cancel() }.onFailure { throwable -> + context.log.error("Failed to cancel task $pendingTask", throwable) + } + }, + colors = IconButtonDefaults.filledIconButtonColors(containerColor = Color(0xFFFF6B9B).copy(alpha = 0.35f), contentColor = Color.White) + ) { Icon(Icons.Filled.Close, contentDescription = "Cancel") } + } else if (taskStatus == TaskStatus.SUCCESS) { + AnimatedVisibility( + visible = true, + enter = fadeIn(tween(250)) + scaleIn(tween(300)), + exit = fadeOut(tween(150)) + scaleOut(targetScale = 0.5f, animationSpec = tween(150)) + ) { + Box(modifier = Modifier.size(40.dp).clip(CircleShape).background(PurrfectPalette.glowPrimary.copy(alpha = 0.22f)), contentAlignment = Alignment.Center) { + Icon(Icons.Filled.Check, contentDescription = "Success", tint = Color.White) + } + } + } else { + when (taskStatus) { + TaskStatus.FAILURE -> Icon(Icons.Filled.Error, contentDescription = "Failure", tint = Color(0xFFFF6B9B)) + TaskStatus.CANCELLED -> Icon(Icons.Filled.Cancel, contentDescription = "Cancelled", tint = Color(0xFFFF6B9B)) + else -> {} + } + } + } + } + } +} diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionTheme.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionTheme.kt new file mode 100644 index 00000000..f0694210 --- /dev/null +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionTheme.kt @@ -0,0 +1,61 @@ +package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion + +import androidx.compose.runtime.Composable +import androidx.navigation.NavBackStackEntry +import me.eternal.purrfectsnap.ui.manager.ThemeContract +import me.eternal.purrfectsnap.ui.manager.pages.TasksRootSection +import me.eternal.purrfectsnap.ui.manager.pages.features.FeaturesRootSection +import me.eternal.purrfectsnap.ui.manager.pages.home.HomeAbout +import me.eternal.purrfectsnap.ui.manager.pages.home.HomeLogs +import me.eternal.purrfectsnap.ui.manager.pages.home.HomeRootSection +import me.eternal.purrfectsnap.ui.manager.pages.home.HomeSettings +import me.eternal.purrfectsnap.ui.manager.pages.scripting.ScriptingRootSection +import me.eternal.purrfectsnap.ui.manager.pages.social.SocialRootSection +import me.eternal.purrfectsnap.ui.manager.pages.tracker.FriendTrackerManagerRoot + +object AphelionTheme : ThemeContract { + @Composable + override fun HomeRootSection.HomeScreen(nav: NavBackStackEntry) { + AphelionHomeScreen(nav) + } + + @Composable + override fun HomeSettings.SettingsScreen(nav: NavBackStackEntry) { + AphelionSettingsScreen(nav) + } + + @Composable + override fun HomeAbout.AboutScreen(nav: NavBackStackEntry) { + AphelionAboutScreen(nav) + } + + @Composable + override fun HomeLogs.LogsScreen(nav: NavBackStackEntry) { + AphelionLogsScreen(nav) + } + + @Composable + override fun SocialRootSection.SocialScreen(nav: NavBackStackEntry) { + AphelionSocialScreen(nav) + } + + @Composable + override fun TasksRootSection.TasksScreen(nav: NavBackStackEntry) { + AphelionTasksScreen(nav) + } + + @Composable + override fun FeaturesRootSection.FeaturesScreen(nav: NavBackStackEntry) { + AphelionFeaturesScreen(nav) + } + + @Composable + override fun ScriptingRootSection.ScriptingScreen(nav: NavBackStackEntry) { + AphelionScriptingScreen(nav) + } + + @Composable + override fun FriendTrackerManagerRoot.FriendTrackerScreen(nav: NavBackStackEntry) { + TrackerScreenContent(nav) + } +} diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt new file mode 100644 index 00000000..13940d9c --- /dev/null +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt @@ -0,0 +1,1456 @@ +package me.eternal.purrfectsnap.ui.manager.pages.themes.legacy + +import android.os.SystemClock +import android.content.SharedPreferences +import android.content.Intent +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.LocalIndication +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.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.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.Widgets +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +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.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +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 +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex +import androidx.core.content.edit +import androidx.core.net.toUri +import androidx.lifecycle.Lifecycle +import androidx.navigation.NavBackStackEntry +import androidx.navigation.compose.currentBackStackEntryAsState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.eternal.purrfectsnap.LogLine +import me.eternal.purrfectsnap.LogReader +import me.eternal.purrfectsnap.R +import me.eternal.purrfectsnap.action.EnumQuickActions +import me.eternal.purrfectsnap.common.BuildConfig +import me.eternal.purrfectsnap.common.action.EnumAction +import me.eternal.purrfectsnap.common.bridge.InternalFileHandleType +import me.eternal.purrfectsnap.common.config.ConfigContainer +import me.eternal.purrfectsnap.common.config.PropertyPair +import me.eternal.purrfectsnap.common.data.SocialScope +import me.eternal.purrfectsnap.common.ui.TopBarActionButton +import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState +import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList +import me.eternal.purrfectsnap.common.util.ktx.copyToClipboard +import me.eternal.purrfectsnap.common.util.ktx.openLink +import me.eternal.purrfectsnap.storage.getAllScopeNotes +import me.eternal.purrfectsnap.storage.getQuickTiles +import me.eternal.purrfectsnap.storage.setAllScopeNotes +import me.eternal.purrfectsnap.storage.setQuickTiles +import me.eternal.purrfectsnap.ui.manager.ThemeContract +import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog +import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar +import me.eternal.purrfectsnap.ui.manager.data.UpdateDownloader +import me.eternal.purrfectsnap.ui.manager.data.Updater +import me.eternal.purrfectsnap.ui.manager.data.Updater.Channel +import me.eternal.purrfectsnap.ui.manager.pages.TasksRootSection +import me.eternal.purrfectsnap.ui.manager.pages.features.FeaturesRootSection +import me.eternal.purrfectsnap.ui.manager.pages.home.HomeAbout +import me.eternal.purrfectsnap.ui.manager.pages.home.HomeLogs +import me.eternal.purrfectsnap.ui.manager.pages.home.HomeRootSection +import me.eternal.purrfectsnap.ui.manager.pages.home.HomeRootSection.Companion.QUICK_TILES_INITIALIZED_PREF +import me.eternal.purrfectsnap.ui.manager.pages.home.HomeRootSection.Companion.cardMargin +import me.eternal.purrfectsnap.ui.manager.pages.home.HomeRootSection.Companion.pageBackgroundGradient +import me.eternal.purrfectsnap.ui.manager.pages.home.HomeSettings +import me.eternal.purrfectsnap.ui.manager.pages.home.QuickActionsDialog +import me.eternal.purrfectsnap.ui.manager.pages.scripting.ScriptingRootSection +import me.eternal.purrfectsnap.ui.manager.pages.social.SocialRootSection +import me.eternal.purrfectsnap.ui.manager.pages.tracker.FriendTrackerManagerRoot +import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette +import me.eternal.purrfectsnap.ui.setup.Requirements +import me.eternal.purrfectsnap.ui.util.OnLifecycleEvent +import me.eternal.purrfectsnap.ui.util.PurrfectMarqueeText +import me.eternal.purrfectsnap.ui.util.openFile +import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors +import me.eternal.purrfectsnap.ui.util.pullrefresh.PullRefreshIndicator +import me.eternal.purrfectsnap.ui.util.pullrefresh.pullRefresh +import me.eternal.purrfectsnap.ui.util.pullrefresh.rememberPullRefreshState +import me.eternal.purrfectsnap.ui.util.saveFile +import me.eternal.purrfectsnap.ui.util.scaleOnPress +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import java.io.FileOutputStream +import java.net.URLEncoder + +object LegacyTheme : ThemeContract { + @OptIn(ExperimentalLayoutApi::class, ExperimentalAnimationApi::class, ExperimentalMaterial3Api::class) + @Composable override fun HomeRootSection.HomeScreen(nav: NavBackStackEntry) { + + @Composable + fun LocalTopBarActionChip( + icon: ImageVector, + label: String? = null, + contentDescription: String? = label, + onClick: () -> Unit, + ) { + Surface( + shape = RoundedCornerShape(40), + color = Color.White.copy(alpha = 0.06f), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)) + ) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(40)) + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon(icon, contentDescription = contentDescription, tint = Color.White) + label?.let { + Text(text = it, color = Color.White, fontSize = 13.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } + + @Composable + fun RowScope.LocalHomeActionChips() { + LocalTopBarActionChip(icon = Icons.Filled.BugReport, label = context.translation["manager.routes.home_logs"]) { routes.homeLogs.navigate() } + LocalTopBarActionChip(icon = Icons.Filled.Info, label = translation["manager.routes.home_about"]) { routes.about.navigate() } + } + + @Composable + fun LocalHeroSection( + versionName: String, + latestUpdate: Updater.LatestRelease?, + downloadState: UpdateDownloader.DownloadState, + downloadProgress: Float, + onUpdateAction: () -> Unit, + channelLabel: String, + isPurrAuraActive: Boolean, + 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) } + Box( + modifier = Modifier + .padding(horizontal = cardMargin, vertical = 6.dp) + .clip(heroShape) + .background(Brush.linearGradient(heroGradientColors)) + .border(1.dp, Color.White.copy(alpha = 0.1f), heroShape) + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 22.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Text("PurrfectSnap", color = Color.White, fontSize = 34.sp, fontWeight = FontWeight.ExtraBold, fontFamily = avenirNext) + 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) + } + 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)), + tonalElevation = 0.dp, shadowElevation = 0.dp + ) { + Row( + 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, maxLines = 1, overflow = TextOverflow.Ellipsis) + 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 -> + when (state) { + UpdateDownloader.DownloadState.IDLE, + UpdateDownloader.DownloadState.FAILED -> { + 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(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), modifier = Modifier.padding(end = 6.dp)) { + CircularProgressIndicator(progress = { downloadProgress }, modifier = Modifier.size(28.dp), strokeWidth = 3.dp, color = Color.White) + 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 = translation["completed_icon_description"] ?: "", tint = Color(0xFFA3F0C2)) + Text(translation["update_ready_label"] ?: "", color = Color.White, fontWeight = FontWeight.SemiBold) + } + } + } + } + } + } + } + + Surface( + 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 + ) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Surface(shape = RoundedCornerShape(50), 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.padding(horizontal = 14.dp, vertical = 10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { + 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 = 14.sp + ) + } + } + OutlinedButton( + onClick = { routes.settings.navigate() }, + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)), + colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White), + modifier = Modifier.align(Alignment.CenterHorizontally) + ) { + Icon(Icons.Filled.Settings, contentDescription = null, tint = Color.White) + Spacer(modifier = Modifier.width(6.dp)) + Text(translation["open_settings_button"] ?: "") + } + } + } + + Surface(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(26.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.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Button(modifier = Modifier.weight(1f), onClick = { context.androidContext.openLink("https://purrfectsnap.vercel.app/", context.translation["toast_open_link_failed"]) }, colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = 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), onClick = { context.androidContext.openLink("https://github.com/particle-box/PurrfectSnap", context.translation["toast_open_link_failed"]) }, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.35f)), colors = ButtonDefaults.outlinedButtonColors(contentColor = 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 = { context.androidContext.openLink("https://t.me/purrfectsnap_official", context.translation["toast_open_link_failed"]) }, + tint = Color.White, containerColor = Color.White.copy(alpha = 0.14f) + ) + } + } + } + } + } + + 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)) { + 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 coroutineScope = rememberCoroutineScope() + 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 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 } + } + } + 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" + UpdateDownloader.downloadAndInstall(context, downloadUrl, "$artifactName.zip", coroutineScope) + } + return@let + } + val releaseDownload = abiName?.let { arch -> latest.assetDownloads[arch] } + if (releaseDownload != null) { + val fileName = releaseDownload.substringAfterLast('/') + UpdateDownloader.downloadAndInstall(context, releaseDownload, fileName, coroutineScope) + } else { + 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 } + } + } + } + + fun loadAnnouncements() { + if (announcementsText != null) return + announcementsLoading = true; announcementsError = null + coroutineScope.launch(Dispatchers.IO) { + runCatching { + changelogClient.newCall(Request.Builder().url(announcementsUrl).build()).execute().use { response -> + if (!response.isSuccessful) throw IllegalStateException("Failed to fetch announcements (${response.code})") + response.body?.string()?.trim() ?: throw IllegalStateException("Empty announcements body") + } + }.onSuccess { text -> + withContext(Dispatchers.Main) { announcementsText = text; announcementsLoading = false } + }.onFailure { error -> + withContext(Dispatchers.Main) { announcementsError = error.message ?: "Failed to load announcements"; announcementsLoading = false } + } + } + } + + 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 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) { + LocalTopBarActionChip(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) { + LocalHomeActionChips() + } + } + Spacer(modifier = Modifier.height(12.dp)) + LocalHeroSection( + versionName = BuildConfig.VERSION_NAME, + latestUpdate = latestUpdate, + downloadState = downloadState, + downloadProgress = downloadProgress, + onUpdateAction = onUpdateButtonClick, + channelLabel = channelLabel, + isPurrAuraActive = isPurrAuraActive, + 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 + ) + 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), 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(Icons.Outlined.Widgets, contentDescription = translation["quick_actions_icon_description"], modifier = Modifier.size(72.dp), tint = Color.White) + Spacer(modifier = Modifier.height(16.dp)) + Text(translation["quick_actions_empty_title"] ?: "", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = Color.White) + Spacer(modifier = Modifier.height(8.dp)) + 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(Icons.Default.Add, contentDescription = translation["add_quick_action_description"], modifier = Modifier.size(20.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text(translation["quick_actions_add_tile_button"] ?: "") + } + } + } else { + Column(modifier = Modifier.fillMaxWidth().padding(bottom = 18.dp), verticalArrangement = Arrangement.spacedBy(10.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Text(translation["quick_actions_title"] ?: "", fontSize = 24.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center, color = Color.White, maxLines = 3, overflow = TextOverflow.Clip) + 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(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(tileWidth).aspectRatio(1.05f).scaleOnPress(interactionSource).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(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 = card.first, + lineHeight = 16.sp, + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + color = Color.White, + overflow = TextOverflow.Ellipsis, + maxLines = 2, + ) + } + } + } + } + } + } + } + } + } + } + } + Spacer(modifier = Modifier.height(32.dp)) + } + + if (showChangelogDialog) { + AestheticDialog( + onDismissRequest = { showChangelogDialog = false }, + title = translation["changelog_dialog_title"] ?: "Changelog", + text = "", icon = Icons.Filled.Info, + confirmButtonText = translation["changelog_dialog_update_button"] ?: "Update", + onConfirm = { showChangelogDialog = false; handleUpdateAction() }, + dismissButtonText = translation["changelog_dialog_cancel_button"] ?: "Cancel", + onDismiss = { showChangelogDialog = false }, + customContent = { + Column(modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) { + if (changelogLoading) CircularProgressIndicator(color = Color.White) + else if (changelogError != null) Text(changelogError!!, color = Color.Red, fontSize = 14.sp) + else Text(changelogText ?: translation["changelog_dialog_empty"] ?: "", color = PurrfectPalette.textPrimary, fontSize = 14.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 }, + customContent = { + Column(modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) { + if (announcementsLoading) CircularProgressIndicator(color = Color.White) + else if (announcementsError != null) Text(announcementsError!!, color = Color.Red, fontSize = 14.sp) + else Text(announcementsText ?: translation["announcements_dialog_empty"] ?: "", color = PurrfectPalette.textPrimary, fontSize = 14.sp) + } + } + ) + } + + if (showQuickActionsMenu) { + QuickActionsDialog( + quickActions = cards, + selectedQuickActions = selectedTiles, + onDismiss = { showQuickActionsMenu = false }, + onSave = { newList -> + val removed = selectedTiles.filter { it !in newList } + removed.forEach { clearTileSpan(it); clearTileOffset(it) } + selectedTiles.clear(); selectedTiles.addAll(newList) + context.coroutineScope.launch { context.database.setQuickTiles(selectedTiles) } + showQuickActionsMenu = false + }, + translation = translation + ) + } + } + + @OptIn(ExperimentalMaterial3Api::class) + @Composable override fun HomeSettings.SettingsScreen(nav: NavBackStackEntry) { + val scope = rememberCoroutineScope() + val scrollState = rememberScrollState() + val hapticFeedback = LocalHapticFeedback.current + val positiveLabel = context.translation["button.positive"] + val negativeLabel = context.translation["button.negative"] + val importLabel = context.translation["button.import"] + val sharedButtonColors = ButtonDefaults.buttonColors( + containerColor = Color.White.copy(alpha = 0.12f), + contentColor = Color.White + ) + val sharedOutlinedColors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White) + var showResetSetupDialog by remember { mutableStateOf(false) } + + val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + Box( + modifier = Modifier + .fillMaxSize() + .background(PurrfectPalette.backgroundGradient) + ) { + if (showResetSetupDialog) { + AestheticDialog( + onDismissRequest = { showResetSetupDialog = false }, + title = translation["reset_setup_dialog_title"], + text = translation["reset_setup_dialog_text"], + icon = Icons.Filled.Warning, + confirmButtonText = positiveLabel, + dismissButtonText = negativeLabel, + onConfirm = { + showResetSetupDialog = false + context.sharedPreferences.edit() + .remove("setup_in_progress") + .remove("setup_current_route") + .remove("setup_skip_patch") + .remove("setup_install_mode") + .apply() + + context.config.reset() + context.config.writeConfig() + + val intent = Intent(context.androidContext, me.eternal.purrfectsnap.ui.setup.SetupActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + context.androidContext.startActivity(intent) + routes.navController.popBackStack() + }, + onDismiss = { showResetSetupDialog = false }, + showCloseButton = false + ) + } + + Column(modifier = Modifier.fillMaxSize()) { + Spacer(modifier = Modifier.height(topPadding)) + + Surface( + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp), + shape = RoundedCornerShape(26.dp), + color = Color.White.copy(alpha = 0.07f), + border = BorderStroke(1.dp, Brush.linearGradient(listOf(Color.White.copy(alpha = 0.12f), Color.White.copy(alpha = 0.05f)))), + 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"] ?: "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["ui_theme_title"] ?: "UI Theme") + ShiftedRow { + Row( + modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(text = translation["settings_ui_theme"] ?: "Aphelion Theme", fontSize = 14.sp) + val currentThemeId = context.config.root.global.uiSettings.managerTheme.get() + Switch( + checked = currentThemeId == "APHELION", + onCheckedChange = { isAphelion -> + if (context.config.root.global.uiSettings.hapticFeedback.get()) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + } + val newId = if (isAphelion) "APHELION" else "LEGACY" + context.config.root.global.uiSettings.managerTheme.set(newId) + context.config.writeConfig() + }, + modifier = Modifier.padding(end = 26.dp), + colors = purrfectSwitchColors() + ) + } + } + } + + GlassCard { + RowTitle(title = translation["actions_title"]) + EnumAction.entries.forEach { enumAction -> + RowAction(key = enumAction.key) { context.launchActionIntent(enumAction) } + } + RowAction(key = "regen_mappings") { context.checkForRequirements(Requirements.MAPPINGS) } + RowAction(key = "change_language") { context.checkForRequirements(Requirements.LANGUAGE) } + } + + GlassCard { + RowTitle(title = translation["ui_settings_title"]) + ShiftedRow { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { + Text(text = translation["haptic_feedback_label"], fontSize = 14.sp) + var hapticEnabled by remember { mutableStateOf(context.config.root.global.uiSettings.hapticFeedback.getNullable() ?: true) } + Switch(checked = hapticEnabled, onCheckedChange = { if (it) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); hapticEnabled = 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"], fontSize = 14.sp) + 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()) + } + } + } + } + + GlassCard { + RowTitle(title = translation["updates_title"]) + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + var autoUpdateCheck by remember { mutableStateOf(context.config.root.global.updateSettings.autoUpdateCheck.getNullable() ?: true) } + var selectedChannel by remember { mutableStateOf(context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable") } + var channelMenuExpanded by remember { mutableStateOf(false) } + ShiftedRow { + Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { + Text(text = translation["auto_update_check"], fontSize = 14.sp) + 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) { + ExposedDropdownMenuBox(expanded = channelMenuExpanded, onExpandedChange = { channelMenuExpanded = it }, modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp)) { + AestheticDropdownField(value = translation.getOrNull("update_channel_${selectedChannel}") ?: selectedChannel, expanded = channelMenuExpanded, modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), onClick = { channelMenuExpanded = true }) + ExposedDropdownMenu(expanded = channelMenuExpanded, onDismissRequest = { channelMenuExpanded = false }) { + listOf("stable", "prerelease").forEach { channel -> + DropdownMenuItem(text = { Text(text = translation.getOrNull("update_channel_${channel}") ?: channel) }, onClick = { selectedChannel = channel; channelMenuExpanded = false; context.config.root.global.updateSettings.updateChannel.set(channel); context.config.writeConfig(); scheduleUpdateCheck() }) + } + } + } + } + } + } + + GlassCard { + RowTitle(title = translation["reset_setup_title"]) + ShiftedRow(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp).clickable { showResetSetupDialog = true }, horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text(text = translation["reset_setup_action"], fontSize = 16.sp, fontWeight = FontWeight.Medium, lineHeight = 20.sp) + Icon(imageVector = Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null, modifier = Modifier.padding(end = 14.dp)) + } + } + + GlassCard { + RowTitle(title = translation["message_logger_title"]) + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + var storedMessagesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredMessageCount() } + var storedStoriesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredStoriesCount() } + var showImportDialog by remember { mutableStateOf(false) } + Column(modifier = Modifier.fillMaxWidth().padding(5.dp), verticalArrangement = Arrangement.spacedBy(10.dp), horizontalAlignment = Alignment.CenterHorizontally) { + val summary = translation.format("message_logger_summary", "messageCount" to storedMessagesCount.toString(), "storyCount" to storedStoriesCount.toString()).replace("\n", " | ") + Text(summary, maxLines = 2, color = Color.White, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) + FlowRow(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(10.dp) ) { + Button(onClick = { runCatching { activityLauncherHelper.saveFile("message_logger.db", "application/octet-stream") { uri -> context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { out -> context.messageLogger.databaseFile.inputStream().use { it.copyTo(out) } } } }.onFailure { context.log.error("Failed to export", it) } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["export_button"]) } + Button(onClick = { runCatching { activityLauncherHelper.openFile("application/octet-stream") { uri -> val tempFile = File(context.androidContext.cacheDir, "view_logger.db"); context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { it.copyTo(tempFile.outputStream()) }; routes.viewLoggerHistory.navigate { put("uri", URLEncoder.encode(tempFile.toUri().toString(), "UTF-8")) } } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["view_button"]) } + Button(onClick = { runCatching { context.messageLogger.purgeAll(); storedMessagesCount = 0; storedStoriesCount = 0 }.onSuccess { context.shortToast(translation["success_toast"]) } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["clear_button"]) } + Button(onClick = { showImportDialog = true }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["import_button"]) } + } + } + OutlinedButton(modifier = Modifier.fillMaxWidth().padding(5.dp), onClick = { routes.loggerHistory.navigate() }, colors = sharedOutlinedColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.2f))) { Text(translation["view_logger_history_button"]) } + if (showImportDialog) { + AestheticDialog(onDismissRequest = { showImportDialog = false }, title = translation["message_logger_import_title"], text = translation["message_logger_import_text"], icon = Icons.Filled.Info, confirmButtonText = importLabel, dismissButtonText = context.translation["button.cancel"], onConfirm = { showImportDialog = false; runCatching { activityLauncherHelper.openFile("application/octet-stream") { uri -> context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { context.messageLogger.databaseFile.outputStream().use { out -> it.copyTo(out) } }; storedMessagesCount = context.messageLogger.getStoredMessageCount(); storedStoriesCount = context.messageLogger.getStoredStoriesCount(); context.shortToast(translation["success_toast"]) } } }, onDismiss = { showImportDialog = false }, showCloseButton = false) + } + } + } + + GlassCard { + RowTitle(title = translation["friend_notes_title"]) + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(text = translation["friend_notes_description"], modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp), color = Color.White, textAlign = TextAlign.Center) + Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Button(onClick = { runCatching { val notes = context.database.getAllScopeNotes(); if (notes.isEmpty()) return@runCatching; val json = context.gson.toJson(notes); activityLauncherHelper.saveFile("notes.json", "application/json") { uri -> context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { it.write(json.toByteArray()) }; context.shortToast(translation["friend_notes_backup_success"]) } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["backup_button"]) } + Button(onClick = { runCatching { activityLauncherHelper.openFile("application/json") { uri -> context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { val json = it.reader().readText(); val notes = context.gson.fromJson>(json, object : com.google.gson.reflect.TypeToken>() {}.type); context.database.setAllScopeNotes(notes); context.shortToast(translation["friend_notes_restore_success"]) } } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["restore_button"]) } + } + } + } + } + + GlassCard { + RowTitle(title = translation["debug_title"]) + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp)) { + var selectedFileType by remember { mutableStateOf(InternalFileHandleType.entries.first()) } + var expanded by remember { mutableStateOf(false) } + Box(modifier = Modifier.weight(1f)) { + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }, modifier = Modifier.fillMaxWidth()) { + AestheticDropdownField(value = translation.getOrNull("debug_file_${selectedFileType.name.lowercase()}") ?: selectedFileType.fileName, expanded = expanded, modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), onClick = { expanded = true }) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + InternalFileHandleType.entries.forEach { fileType -> DropdownMenuItem(onClick = { expanded = false; selectedFileType = fileType }, text = { Text(text = translation.getOrNull("debug_file_${fileType.name.lowercase()}") ?: fileType.fileName) }) } + } + } + } + Button(onClick = { runCatching { scope.launch { selectedFileType.resolve(context.androidContext).delete() } }.onSuccess { context.shortToast(translation["success_toast"]) } }, colors = ButtonDefaults.buttonColors(containerColor = Color.White.copy(alpha = 0.1f), contentColor = Color.White), 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)) + Spacer(modifier = Modifier.width(8.dp)); Text(translation["clear_button"]) + } + } + ShiftedRow { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + PremiumPreferenceToggle(context.sharedPreferences, key = "test_mode", text = translation["test_mode_label"], defaultValue = true, confirmDisableTitle = translation["purr_aura_disable_title"], confirmDisableText = translation["purr_aura_disable_text"]) + PreferenceToggle(context.sharedPreferences, key = "disable_feature_loading", text = translation["disable_feature_loading_label"]) + PreferenceToggle(context.sharedPreferences, key = "disable_mapper", text = translation["disable_auto_mapper_label"]) + PreferenceToggle(context.sharedPreferences, key = "disable_bypass_indicator", text = translation["disable_bypass_indicator_label"]) + } + } + } + } + + Spacer(modifier = Modifier.height(routes.bottomPadding + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 24.dp)) + } + } + } + } + } + + @Composable override fun HomeAbout.AboutScreen(nav: NavBackStackEntry) { + val avenirNext = remember { FontFamily(Font(R.font.avenir_next_medium, FontWeight.Medium)) } + val scrollState = rememberScrollState() + val aboutStory = remember { translation["about_story"] ?: "" } + val pagePadding = 16.dp + val bottomPadding = routes.bottomPadding + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 24.dp + val tapSource = remember { MutableInteractionSource() } + val tapCount = remember { mutableIntStateOf(0) } + val lastTapTime = remember { mutableStateOf(0L) } + + Box( + modifier = Modifier + .fillMaxSize() + .background(PurrfectPalette.backgroundGradient) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(bottom = bottomPadding) + ) { + FloatingTopBar( + title = routeInfo.translatedKey?.value ?: translation["manager.routes.home_about"] ?: "About", + onBack = { routes.navController.popBackStack() } + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Surface( + modifier = Modifier.padding(horizontal = pagePadding).fillMaxWidth(), + shape = RoundedCornerShape(30.dp), + color = Color.Transparent, + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)), + tonalElevation = 0.dp, + shadowElevation = 0.dp + ) { + Column( + modifier = Modifier.background(PurrfectPalette.panelGradient).padding(horizontal = 22.dp, vertical = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = translation["about_title"] ?: "About", + fontSize = 28.sp, + fontWeight = FontWeight.ExtraBold, + color = PurrfectPalette.textPrimary, + fontFamily = avenirNext, + modifier = Modifier.clickable(interactionSource = tapSource, indication = null) { + val now = SystemClock.elapsedRealtime() + if (now - lastTapTime.value > 1500L) { tapCount.intValue = 0 } + tapCount.intValue += 1 + lastTapTime.value = now + if (tapCount.intValue >= 3 && tapCount.intValue < 5) { + context.shortToast(translation.format("magic_toast", "count" to (5 - tapCount.intValue).toString())) + } + if (tapCount.intValue >= 5) { tapCount.intValue = 0; routes.retroGame.navigate() } + } + ) + Text(text = translation["about_tagline"] ?: "", fontSize = 13.sp, color = PurrfectPalette.textSecondary, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) + Text(text = translation["about_lead_developers_title"] ?: "Lead Developers", fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = Color.White, modifier = Modifier.padding(top = 10.dp)) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically) { + DeveloperCard(name = "ΞTΞRNAL", imageRes = R.drawable.pfp_external, avenirNext = avenirNext, modifier = Modifier.weight(1f)) + DeveloperCard(name = "", imageRes = R.drawable.pfp_rsr, avenirNext = avenirNext, modifier = Modifier.weight(1f)) + } + } + } + + Spacer(modifier = Modifier.height(14.dp)) + + Surface( + modifier = Modifier.padding(horizontal = pagePadding).fillMaxWidth(), + shape = RoundedCornerShape(26.dp), + color = Color.Transparent, + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)), + tonalElevation = 0.dp, shadowElevation = 0.dp + ) { + Column( + modifier = Modifier.background(PurrfectPalette.cardOverlay).padding(horizontal = 20.dp, vertical = 18.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text(text = translation["about_story_title"] ?: "Our Story", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = Color.White) + Text(text = aboutStory, fontSize = 14.sp, color = PurrfectPalette.textSecondary, lineHeight = 20.sp) + } + } + + Spacer(modifier = Modifier.height(14.dp)) + + Surface( + modifier = Modifier.padding(horizontal = pagePadding).fillMaxWidth(), + shape = RoundedCornerShape(24.dp), + color = Color.White.copy(alpha = 0.08f), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)), + tonalElevation = 0.dp, shadowElevation = 0.dp + ) { + Column( + modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(text = translation["about_thanks_title"] ?: "", fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = Color.White, maxLines = 2, overflow = TextOverflow.Ellipsis) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + Button(modifier = Modifier.weight(1f), onClick = { context.androidContext.openLink("https://github.com/particle-box/PurrfectSnap", context.translation["toast_open_link_failed"] ?: "") }, colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color(0xFF1B152E))) { + Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_github), contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = translation["github_button"] ?: "GitHub", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + OutlinedButton(modifier = Modifier.weight(1f), onClick = { context.androidContext.openLink("https://t.me/purrfectsnap_official", context.translation["toast_open_link_failed"] ?: "") }, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.35f)), colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White)) { + Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_telegram), contentDescription = null, modifier = Modifier.size(18.dp), tint = Color.White) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = translation["telegram_button"] ?: "Telegram", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } + Spacer(modifier = Modifier.height(32.dp)) + } + } + } + + @Composable override fun HomeLogs.LogsScreen(nav: NavBackStackEntry) { + val coroutineScope = rememberCoroutineScope() + 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 + mainExecutor.execute { + visibleLogs.add(line) + } + } + } + } + readerResult.onFailure { + context.longToast(translation["read_logs_failed_toast"]) + } + readerResult.getOrNull()?.let { reader -> + logReader = reader + val filteredLogs = withContext(Dispatchers.IO) { + (0 until reader.lineCount).mapNotNull { index -> + reader.getLogLine(index)?.takeUnless(::shouldHideLog) + } + } + visibleLogs.clear() + visibleLogs.addAll(filteredLogs) + } + delay(220) + if (visibleLogs.isNotEmpty()) { + val targetIndex = (visibleLogs.size - 1).coerceAtLeast(0) + logListState.scrollToItem(targetIndex) + } + isRefreshing = false + } + } + LaunchedEffect(externalRefreshTick.intValue) { + if (externalRefreshTick.intValue > 0) { + isRefreshing = true + refreshLogs() + } + } + val pullRefreshState = rememberPullRefreshState(isRefreshing, onRefresh = { + isRefreshing = true + refreshLogs() + }) + LaunchedEffect(Unit) { + isRefreshing = true + refreshLogs() + } + Box( + modifier = Modifier + .fillMaxSize() + .background(PurrfectPalette.backgroundGradient) + .pullRefresh(pullRefreshState) + ) { + Column(modifier = Modifier.fillMaxSize()) { + this@LogsScreen.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) { + this@LogsScreen.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 -> + this@LogsScreen.LogEntryCard(line = line, composeContext = composeContext) + } + } + } + } + } + PullRefreshIndicator( + refreshing = isRefreshing, + state = pullRefreshState, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 8.dp) + ) + } + } + + @Composable override fun SocialRootSection.SocialScreen(nav: NavBackStackEntry) { + val titles = remember { + listOf(translation["friends_tab"], translation["groups_tab"]) + } + val coroutineScope = rememberCoroutineScope() + val pagerState = rememberPagerState { titles.size } + var searchQuery by rememberSaveable { mutableStateOf("") } + var searchActive by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(Unit) { + context.database.receiveMessagingDataCallback = { friends, groups -> + friendList = friends + groupList = groups + } + updateScopeLists() + requestLatestSnapshot() + } + DisposableEffect(Unit) { + onDispose { + context.database.receiveMessagingDataCallback = { _, _ -> } + } + } + val normalizedQuery = remember(searchQuery) { searchQuery.trim() } + val filteredFriends = remember(friendList, normalizedQuery) { + if (normalizedQuery.isBlank()) { + friendList + } else { + friendList.filter { + it.mutableUsername.contains(normalizedQuery, ignoreCase = true) || + it.displayName?.contains(normalizedQuery, ignoreCase = true) == true + } + } + } + val filteredGroups = remember(groupList, normalizedQuery) { + if (normalizedQuery.isBlank()) { + groupList + } else { + groupList.filter { it.name.contains(normalizedQuery, ignoreCase = true) } + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(PurrfectPalette.backgroundGradient) + ) { + SocialHeader( + titles = titles, + pagerState = pagerState, + onTabSelected = { index -> + coroutineScope.launch { pagerState.animateScrollToPage(index) } + }, + friendCount = friendList.size, + groupCount = groupList.size, + searchActive = searchActive, + onSearchToggle = { + searchActive = !searchActive + if (!searchActive) searchQuery = "" + } + ) + if (searchActive) { + val searchHint = context.translation["manager.dialogs.add_friend.search_hint"] + val searchShape = RoundedCornerShape(18.dp) + val searchBorder = Brush.linearGradient( + listOf( + PurrfectPalette.glowPrimary.copy(alpha = 0.45f), + PurrfectPalette.glowSecondary.copy(alpha = 0.35f) + ) + ) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 6.dp), + shape = searchShape, + color = Color.White.copy(alpha = 0.05f), + border = BorderStroke(1.dp, searchBorder), + tonalElevation = 0.dp, + shadowElevation = 0.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(PurrfectPalette.cardOverlay, searchShape) + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = searchHint, + tint = PurrfectPalette.textSecondary + ) + BasicTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + singleLine = true, + textStyle = MaterialTheme.typography.bodyMedium.copy( + color = Color.White, + fontSize = 15.sp + ), + cursorBrush = SolidColor(PurrfectPalette.glowSecondary), + modifier = Modifier.weight(1f) + ) { innerTextField -> + if (searchQuery.isEmpty()) { + Text( + text = searchHint, + color = PurrfectPalette.textSecondary, + fontSize = 14.sp + ) + } + innerTextField() + } + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { searchQuery = "" }) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = translation["clear_search_button_description"], + tint = Color.White + ) + } + } + } + } + } + Spacer(Modifier.height(12.dp)) + HorizontalPager( + modifier = Modifier + .fillMaxSize(), + state = pagerState + ) { page -> + when (page) { + 0 -> ScopeList(SocialScope.FRIEND, filteredFriends, filteredGroups) + 1 -> ScopeList(SocialScope.GROUP, filteredFriends, filteredGroups) + } + } + } + } + + @Composable override fun TasksRootSection.TasksScreen(nav: NavBackStackEntry) { + val scope = rememberCoroutineScope() + val listState = rememberLazyListState() + var showConfirmDialog by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + fetchActiveTasks(this) + } + + OnLifecycleEvent { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + fetchActiveTasks(scope) + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(PurrfectPalette.backgroundGradient) + ) { + Column(modifier = Modifier.fillMaxSize()) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp) + .padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()), + shape = RoundedCornerShape(26.dp), + color = Color.White.copy(alpha = 0.07f), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + border = BorderStroke( + 1.dp, + Brush.linearGradient( + listOf( + PurrfectPalette.glowPrimary.copy(alpha = 0.55f), + PurrfectPalette.glowSecondary.copy(alpha = 0.35f) + ) + ) + ) + ) { + 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 + ) + if (isRecentTasksInitialized()) { + 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) + ) { + Surface( + shape = RoundedCornerShape(18.dp), + color = Color.White.copy(alpha = 0.08f), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f)) + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon(Icons.Filled.PlaylistAddCheckCircle, contentDescription = null, tint = Color.White) + Text( + text = translation.format("running_count", "count" to activeTasks.size.toString()), + color = Color.White, + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp + ) + } + } + IconButton(onClick = { showConfirmDialog = true }) { + Icon(Icons.Filled.Delete, contentDescription = translation["clear_button_description"], tint = Color.White) + } + } + } + } + + Surface( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 12.dp), + shape = RoundedCornerShape(22.dp), + color = Color.White.copy(alpha = 0.04f), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)) + ) { + LazyColumn( + state = listState, + 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() && (if (isRecentTasksInitialized()) recentTasks.isEmpty() else true)) { + TasksEmptyState(text = translation["no_tasks"] ?: "No tasks") + } + } + + items(activeTasks, key = { it.taskId }) { pendingTask -> + TaskCard(modifier = Modifier.fillMaxWidth(), pendingTask.task, pendingTask = pendingTask) + } + + if (isRecentTasksInitialized()) { + items(recentTasks, key = { it.hash }) { task -> + TaskCard(modifier = Modifier.fillMaxWidth(), task) + } + } + + item { + Spacer(modifier = Modifier.height(40.dp)) + LaunchedEffect(remember { derivedStateOf { listState.firstVisibleItemIndex } }) { + fetchNewRecentTasks() + } + } + } + } + } + } + } + + @Composable override fun FeaturesRootSection.FeaturesScreen(nav: NavBackStackEntry) { + Container(context.config.root, stateKey = "${routeInfo.id}:container:root") + } + @Composable override fun ScriptingRootSection.ScriptingScreen(nav: NavBackStackEntry) { + val scriptingFolder by rememberAsyncMutableState( + defaultValue = null, + updateDispatcher = reloadDispatcher + ) { context.scriptManager.getScriptsFolder() } + val tabTitles = listOf(translation["installed_scripts_tab"], translation["catalog_tab"]) + var showImportDialog by remember { mutableStateOf(false) } + var showToast by remember { mutableStateOf(false) } + + LaunchedEffect(scriptingFolder) { + if (scriptingFolder == null && selectedTab != 0) { + selectedTab = 0 + } + } + + if (showImportDialog) { + ImportRemoteScript { showImportDialog = false } + } + if (showToast) { + LaunchedEffect(showToast) { + context.shortToast(translation["select_scripts_folder_toast"]) + showToast = false + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(PurrfectPalette.backgroundGradient) + ) { + ScriptingHeader( + titles = tabTitles, + selectedTab = selectedTab, + onTabSelected = { index -> + if (index == 1 && scriptingFolder == null) { + showToast = true + } else { + selectedTab = index + } + }, + onImport = { + if (scriptingFolder == null) showToast = true else showImportDialog = true + }, + onOpenFolder = { + if (scriptingFolder == null) { + showToast = true + } else { + scriptingFolder?.let { + context.androidContext.openLink( + it.uri.toString(), + context.translation["toast_open_link_failed"] + ) + } + } + }, + onManageRepos = { routes.manageScriptRepos.navigate() }, + onDocs = { + context.androidContext.openLink( + "https://github.com/SnapEnhance/scripting-docs", + context.translation["toast_open_link_failed"] + ) + }, + folderSelected = scriptingFolder != null + ) + Spacer(Modifier.height(12.dp)) + when (selectedTab) { + 0 -> InstalledTabContent( + scriptingFolder = scriptingFolder + ) + 1 -> CatalogTabContent( + scriptingFolder = scriptingFolder + ) + } + } + } + @Composable + override fun FriendTrackerManagerRoot.FriendTrackerScreen(nav: NavBackStackEntry) { + TrackerScreenContent(nav) + } +} 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 78780f2c..44582ab8 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 @@ -25,13 +25,7 @@ import androidx.compose.material.icons.filled.FileOpen import androidx.compose.material.icons.filled.Store import androidx.compose.ui.window.Dialog import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -44,6 +38,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavBackStackEntry +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList @@ -51,6 +46,7 @@ import me.eternal.purrfectsnap.common.ui.rememberAsyncUpdateDispatcher import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie import me.eternal.purrfectsnap.storage.* import me.eternal.purrfectsnap.ui.manager.Routes +import me.eternal.purrfectsnap.ui.manager.ManagerTheme import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper @@ -67,18 +63,18 @@ class FriendTrackerManagerRoot : Routes.Route() { } override val translation by lazy { context.translation.getCategory("manager.friend_tracker") } - private val titles by lazy { + internal val titles by lazy { listOf( translation["rules_tab"], translation["logs_tab"] ) } - private var currentPage by mutableIntStateOf(0) - private lateinit var logDeleteAction : () -> Unit - private lateinit var exportAction : () -> Unit + internal var currentPage by mutableIntStateOf(0) + internal lateinit var logDeleteAction : () -> Unit + internal lateinit var exportAction : () -> Unit @Composable - private fun TrackerIconButton( + internal fun TrackerIconButton( icon: ImageVector, contentDescription: String?, modifier: Modifier = Modifier, @@ -114,7 +110,7 @@ class FriendTrackerManagerRoot : Routes.Route() { } @Composable - private fun TrackerActionButton( + internal fun TrackerActionButton( label: String, icon: ImageVector, onClick: () -> Unit, @@ -156,7 +152,7 @@ class FriendTrackerManagerRoot : Routes.Route() { } @Composable - private fun TrackerPillButton( + internal fun TrackerPillButton( label: String, icon: ImageVector, onClick: () -> Unit, @@ -304,7 +300,7 @@ class FriendTrackerManagerRoot : Routes.Route() { } } - private lateinit var activityLauncherHelper: ActivityLauncherHelper + internal lateinit var activityLauncherHelper: ActivityLauncherHelper override val init: () -> Unit = { activityLauncherHelper = ActivityLauncherHelper(context.activity!!) @@ -347,7 +343,7 @@ class FriendTrackerManagerRoot : Routes.Route() { } @Composable - private fun ConfigRulesTab() { + internal fun ConfigRulesTab() { val updateRules = rememberAsyncUpdateDispatcher() val rules = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = updateRules) { context.database.getTrackerRulesDesc() @@ -568,9 +564,9 @@ class FriendTrackerManagerRoot : Routes.Route() { } } - @OptIn(ExperimentalFoundationApi::class) - override val content: @Composable (NavBackStackEntry) -> Unit = { + @Composable + internal fun TrackerScreenContent(nav: NavBackStackEntry) { val coroutineScope = rememberCoroutineScope() val pagerState = rememberPagerState(initialPage = 0) { titles.size } currentPage = pagerState.currentPage @@ -625,47 +621,47 @@ class FriendTrackerManagerRoot : Routes.Route() { ) ) ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + 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.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 ) { - 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 } - ) - } - } + TrackerPillButton( + label = translation["import_button"], + icon = Icons.Default.FolderOpen, + onClick = { showImportDialog = true } + ) + TrackerPillButton( + label = translation["export_button"], + icon = Icons.Default.SaveAlt, + onClick = { showExportDialog = true } + ) } } + } + } - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(8.dp)) Surface( modifier = Modifier @@ -796,6 +792,22 @@ class FriendTrackerManagerRoot : Routes.Route() { ) } } + + override val content: @Composable (NavBackStackEntry) -> Unit = { nav -> + val themeId by produceState( + initialValue = context.config.root.global.uiSettings.managerTheme.get() + ) { + while (true) { + delay(300) + value = context.config.root.global.uiSettings.managerTheme.get() + } + } + key(themeId) { + with(ManagerTheme.fromId(themeId).theme) { + this@FriendTrackerManagerRoot.FriendTrackerScreen(nav) + } + } + } } @Composable 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 20eeb2df..22da0db8 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,27 +2,16 @@ package me.eternal.purrfectsnap.ui.util import android.content.Context import android.provider.Settings -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.animation.core.* import androidx.compose.foundation.interaction.InteractionSource -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState -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.runtime.* 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 { @@ -37,9 +26,8 @@ fun prefersReducedMotion(context: Context): Boolean { @Composable fun rememberPrefersReducedMotion(): Boolean { - val context = androidx.compose.ui.platform.LocalContext.current + val context = 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) } @@ -47,18 +35,34 @@ 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 = androidx.compose.animation.core.FastOutSlowInEasing): FiniteAnimationSpec { + fun tweenSpec(durationMillis: Int, easing: Easing = 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 = androidx.compose.animation.core.FastOutSlowInEasing): FiniteAnimationSpec { + fun tweenFloatSpec(durationMillis: Int, easing: Easing = 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 @@ -68,23 +72,23 @@ object Motion { } /** - * Apply a subtle scale-down on press for clickable components (cards, buttons, tiles). - * Pass the same [interactionSource] into the clickable component to synchronize state. + * Kinetic scale-down on press using spring physics. */ @Composable fun Modifier.scaleOnPress( interactionSource: InteractionSource, enabled: Boolean = true, - scaleDown: Float = 0.98f + scaleDown: Float = 0.96f ): Modifier { val pressed by interactionSource.collectIsPressedAsState() val target = if (pressed) scaleDown else 1f - val spec = Motion.tweenFloatSpec(150) - val animated by androidx.compose.animation.core.animateFloatAsState( + + val animated by animateFloatAsState( targetValue = target, - animationSpec = spec, + animationSpec = Motion.springDynamic, 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 new file mode 100644 index 00000000..585359c5 --- /dev/null +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/Modifiers.kt @@ -0,0 +1,26 @@ +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 new file mode 100644 index 00000000..fadc92df --- /dev/null +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/PurrfectText.kt @@ -0,0 +1,58 @@ +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.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, + velocity: Dp = 30.dp, + 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 = velocity + ) + } else Modifier + ) + } +} diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index b36aa26d..9b96bbf5 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.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. 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_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_thanks_title": "With love, PurrfectSnap Team", "about_magic_toast": "Tap 5 times in this screen to see some magic 😉!", "github_button": "GitHub", @@ -264,6 +264,8 @@ "read_logs_failed_toast": "Failed to read logs!" }, "home_settings": { + "ui_theme_title": "UI Theme", + "settings_ui_theme": "Aphelion Theme", "actions_title": "Actions", "message_logger_title": "Message Logger", "debug_title": "Debug", diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Global.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Global.kt index f7a7e680..3b3f4ab7 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Global.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Global.kt @@ -70,6 +70,7 @@ class Global : ConfigContainer() { inner class UISettings : ConfigContainer() { val hapticFeedback = boolean("haptic_feedback", true) val useSystemToasts = boolean("use_system_toasts", false) + val managerTheme = unique("manager_theme", "LEGACY", "APHELION") { requireRestart() }.apply { set("LEGACY") } } val updateSettings = container("update_settings", UpdateSettings()) { addFlags(ConfigFlag.HIDDEN) }