Introducing Aphelion Theme

This commit is contained in:
DarkKnight2122
2026-03-10 04:30:04 +05:30
parent b1b6a4ae6f
commit c52f60a29b
29 changed files with 5213 additions and 3422 deletions

View File

@@ -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
}
}
}

View File

@@ -39,6 +39,10 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.layout.size 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.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
@@ -69,12 +73,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.RadioButtonDefaults import androidx.compose.material3.RadioButtonDefaults
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
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.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -116,28 +115,43 @@ import kotlin.math.sin
androidx.compose.animation.ExperimentalAnimationApi::class androidx.compose.animation.ExperimentalAnimationApi::class
) )
class Navigation( class Navigation(
private val context: RemoteSideContext, internal val context: RemoteSideContext,
private val navController: NavHostController, private val navController: NavHostController,
val routes: Routes = Routes(context).also { it.navController = navController } val routes: Routes = Routes(context).also { it.navController = navController }
) { ) {
private val translation by lazy { context.translation.getCategory("manager.navigation") } private val translation by lazy { context.translation.getCategory("manager.navigation") }
var openBottomBarCustomization by mutableStateOf(false) var openBottomBarCustomization by mutableStateOf(false)
var globalScrollOffset by mutableIntStateOf(0)
@Composable @Composable
fun TopBar() { fun TopBar() {
val navBackStackEntry by navController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = remember(navBackStackEntry) { routes.getCurrentRoute(navBackStackEntry) } val currentRoute = remember(navBackStackEntry) { routes.getCurrentRoute(navBackStackEntry) }
if (currentRoute?.routeInfo?.hasOwnTopBar == true) return 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) { val canGoBack = remember(navBackStackEntry) {
currentRoute?.let { !it.routeInfo.primary || it.routeInfo.childIds.contains(routes.currentDestination) } == true currentRoute?.let { !it.routeInfo.primary || it.routeInfo.childIds.contains(routes.currentDestination) } == true
} }
val haptic = LocalHapticFeedback.current
TopAppBar( TopAppBar(
modifier = Modifier.height(headerHeight),
title = { title = {
currentRoute?.apply { currentRoute?.apply {
title?.invoke() ?: routeInfo.translatedKey?.value?.let { title?.invoke() ?: routeInfo.translatedKey?.value?.let {
Text( Text(
text = it, text = it,
maxLines = 1, 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") val backButtonAnimation by animateFloatAsState(if (canGoBack) 1f else 0f, label = "backButton")
Box( Box(
modifier = Modifier 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)) .width(lerp(0.dp, 48.dp, backButtonAnimation))
.height(48.dp) .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) Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
} }
} }
@@ -162,21 +185,34 @@ class Navigation(
actions = { actions = {
currentRoute?.topBarActions?.invoke(this) currentRoute?.topBarActions?.invoke(this)
if (currentRoute?.routeInfo?.id == routes.settings.routeInfo.id) { 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) Icon(Icons.Filled.Tune, contentDescription = null)
} }
} }
} }
) )
} }
@Composable @Composable
fun FloatingBottomBar() { fun FloatingBottomBar() {
val haptic = LocalHapticFeedback.current
val navBackStackEntry by navController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = remember(navBackStackEntry) { routes.getCurrentRoute(navBackStackEntry) } val currentRoute = remember(navBackStackEntry) { routes.getCurrentRoute(navBackStackEntry) }
val availableRoutes = remember { val availableRoutes = remember {
listOf(routes.tasks, routes.features, routes.home, routes.social, routes.scripting, routes.friendTracker) listOf(routes.tasks, routes.features, routes.home, routes.social, routes.scripting, routes.friendTracker)
} }
val availableRouteMap = remember(availableRoutes) { availableRoutes.associateBy { it.routeInfo.id } } 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 prefs = remember { context.sharedPreferences }
val defaultOrder = remember { listOf("tasks", "features", "home", "social", "scripts") } val defaultOrder = remember { listOf("tasks", "features", "home", "social", "scripts") }
fun loadSelected(): List<String> { fun loadSelected(): List<String> {
@@ -233,9 +269,17 @@ class Navigation(
val animatedBarWidth by animateDpAsState(targetValue = targetBarWidth ?: 0.dp, label = "barWidth") val animatedBarWidth by animateDpAsState(targetValue = targetBarWidth ?: 0.dp, label = "barWidth")
Surface( Surface(
shape = barShape, shape = barShape,
color = Color.Transparent, color = Color.White.copy(alpha = 0.08f),
contentColor = MaterialTheme.colorScheme.onSurface, 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 modifier = Modifier
.then(if (targetBarWidth != null) Modifier.width(animatedBarWidth) else Modifier.fillMaxWidth()) .then(if (targetBarWidth != null) Modifier.width(animatedBarWidth) else Modifier.fillMaxWidth())
.drawBehind { .drawBehind {
@@ -263,7 +307,7 @@ class Navigation(
Box( Box(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(82.dp) .height(barHeight)
.clip(barShape) .clip(barShape)
.background(PurrfectPalette.cardOverlay) .background(PurrfectPalette.cardOverlay)
.border(BorderStroke(1.dp, barBorder), barShape) .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) } var barWidthPx by remember { mutableStateOf(0f) }
val itemCount = selectedRoutes.size.coerceAtLeast(1) val itemCount = selectedRoutes.size.coerceAtLeast(1)
val density = androidx.compose.ui.platform.LocalDensity.current val density = androidx.compose.ui.platform.LocalDensity.current
val selectedIndex = remember(currentRoute, selectedRoutes) { val selectedIndex = remember(currentRoute, selectedRoutes) {
val index = selectedRoutes.indexOf(currentRoute) 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 = val itemWidthPx =
remember(barWidthPx, itemCount) { if (itemCount > 0) barWidthPx / itemCount else 0f } remember(barWidthPx, itemCount) { if (itemCount > 0) barWidthPx / itemCount else 0f }
val offsetAnim = remember { Animatable(0f) } val offsetAnim = remember { Animatable(0f) }
@@ -369,7 +413,7 @@ class Navigation(
.fillMaxHeight() .fillMaxHeight()
.width(indicatorWidth.coerceAtLeast(0.dp)) .width(indicatorWidth.coerceAtLeast(0.dp))
.offset(x = offsetX) .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 } .graphicsLayer { scaleX = scaleXAnim; scaleY = scaleYAnim }
) { ) {
Box( Box(
@@ -430,7 +474,10 @@ class Navigation(
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
.size(22.dp + 2.dp * selectionProgress) .size(22.dp + 2.dp * selectionProgress)
.graphicsLayer { alpha = 0.65f + 0.35f * selectionProgress } .graphicsLayer {
alpha = 0.65f + 0.35f * selectionProgress
translationY = iconTranslationY.toPx()
}
) )
}, },
label = { label = {
@@ -441,11 +488,15 @@ class Navigation(
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
fontSize = 12.sp, fontSize = 12.sp,
fontWeight = FontWeight.SemiBold, 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, maxLines = if (isLong) 2 else 1,
overflow = if (isLong) TextOverflow.Ellipsis else TextOverflow.Clip, overflow = if (isLong) TextOverflow.Ellipsis else TextOverflow.Clip,
softWrap = isLong, 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, selected = isSelected,
@@ -456,7 +507,10 @@ class Navigation(
unselectedTextColor = Color.White.copy(alpha = 0.72f), unselectedTextColor = Color.White.copy(alpha = 0.72f),
indicatorColor = Color.Transparent 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) modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
) )
} else { } else {
val haptic = LocalHapticFeedback.current val hapticCustom = LocalHapticFeedback.current
var draggingId by remember { mutableStateOf<String?>(null) } var draggingId by remember { mutableStateOf<String?>(null) }
var dragDelta by remember { mutableStateOf(0f) } var dragDelta by remember { mutableStateOf(0f) }
var dragStartIndex by remember { mutableStateOf(-1) } var dragStartIndex by remember { mutableStateOf(-1) }
@@ -577,7 +631,7 @@ class Navigation(
draggingId = id draggingId = id
dragStartIndex = selectedTabIds.indexOf(id) dragStartIndex = selectedTabIds.indexOf(id)
dragDelta = 0f dragDelta = 0f
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress) hapticCustom.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
}, },
onDrag = { _: PointerInputChange, dragAmount -> onDrag = { _: PointerInputChange, dragAmount ->
dragDelta += dragAmount.y dragDelta += dragAmount.y
@@ -591,7 +645,7 @@ class Navigation(
list.add(targetIndex, id) list.add(targetIndex, id)
selectedTabIds = list selectedTabIds = list
saveSelected(selectedTabIds) 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() .fillMaxWidth()
.padding(horizontal = 12.dp), .padding(horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp) horizontalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
availableRoutes.forEach { route -> availableRoutes.forEach { route ->
val id = route.routeInfo.id val id = route.routeInfo.id
val already = selectedTabIds.contains(id) val already = selectedTabIds.contains(id)

View File

@@ -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)
}

View File

@@ -1,56 +1,50 @@
package me.eternal.purrfectsnap.ui.manager.components package me.eternal.purrfectsnap.ui.manager.components
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Icon import androidx.compose.material3.*
import androidx.compose.material3.IconButton import androidx.compose.runtime.*
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.Brush
import androidx.compose.ui.graphics.Color 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.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.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.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.PurrfectMarqueeText
import me.eternal.purrfectsnap.ui.util.Motion
@Immutable @Immutable
data class FloatingTopBarColors( data class FloatingTopBarColors(
val container: Color, val container: Color,
val border: Brush val borderStart: Color,
val borderEnd: Color
) )
@Composable @Composable
fun rememberDefaultFloatingTopBarColors(): FloatingTopBarColors { fun rememberDefaultFloatingTopBarColors(): FloatingTopBarColors {
return remember { return remember {
FloatingTopBarColors( FloatingTopBarColors(
container = Color.White.copy(alpha = 0.07f), container = Color.White.copy(alpha = 0.12f),
border = Brush.linearGradient( borderStart = PurrfectPalette.glowPrimary.copy(alpha = 0.6f),
listOf( borderEnd = PurrfectPalette.glowSecondary.copy(alpha = 0.4f)
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
) )
} }
} }
@@ -61,65 +55,216 @@ fun FloatingTopBar(
subtitle: String? = null, subtitle: String? = null,
onBack: (() -> Unit)? = null, onBack: (() -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
scrollOffset: Int = 0,
containerAlpha: Float = 1f,
titleAlignment: Alignment.Horizontal = Alignment.Start,
actions: @Composable RowScope.() -> Unit = {}, actions: @Composable RowScope.() -> Unit = {},
colors: FloatingTopBarColors = rememberDefaultFloatingTopBarColors() colors: FloatingTopBarColors = rememberDefaultFloatingTopBarColors()
) { ) {
val shape = RoundedCornerShape(26.dp) val haptic = LocalHapticFeedback.current
Surface( val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
modifier = modifier
.fillMaxWidth() val focusFactor by remember(scrollOffset) {
.padding(horizontal = 14.dp, vertical = 12.dp) derivedStateOf { (scrollOffset.toFloat() / Motion.HEADER_MORPH_THRESHOLD).coerceIn(0f, 1f) }
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()), }
shape = shape,
color = colors.container, val morphingParams by remember(focusFactor, statusBarHeight) {
tonalElevation = 0.dp, derivedStateOf {
shadowElevation = 0.dp, object {
border = BorderStroke(1.dp, colors.border) val headerHeight = lerp(64.dp, 56.dp, focusFactor)
) { val sidePadding = lerp(14.dp, 0.dp, focusFactor)
Row( val containerTopPadding = lerp(statusBarHeight + 4.dp, 0.dp, focusFactor)
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), val internalTopPadding = lerp(0.dp, statusBarHeight, focusFactor)
verticalAlignment = Alignment.CenterVertically, val internalVerticalPadding = lerp(8.dp, 0.dp, focusFactor)
horizontalArrangement = Arrangement.spacedBy(10.dp) 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) { Box(
IconButton(onClick = onBack, modifier = Modifier.size(42.dp)) { modifier = Modifier
Icon( .fillMaxWidth()
imageVector = Icons.AutoMirrored.Filled.ArrowBack, .background(
contentDescription = null, Brush.verticalGradient(
tint = Color.White listOf(
Color(0xFF1B152E).copy(alpha = 0.85f + (0.1f * focusFactor)),
refractiveColor.copy(alpha = 0.85f + (0.1f * focusFactor))
)
)
) )
} .drawBehind {
} else { val strokeWidth = 1.dp.toPx()
Spacer(modifier = Modifier.height(0.dp)) val brush = Brush.linearGradient(listOf(colors.borderStart, colors.borderEnd))
} val tr = morphingParams.topCorners.toPx()
val br = morphingParams.bottomCorners.toPx()
Column(modifier = Modifier.weight(1f)) {
Text( if (focusFactor > 0.9f) {
text = title, uPath.reset()
color = Color.White, uPath.apply {
fontWeight = FontWeight.ExtraBold, moveTo(0f, 0f)
fontSize = 18.sp, lineTo(0f, size.height - br)
maxLines = 1, arcTo(androidx.compose.ui.geometry.Rect(0f, size.height - 2*br, 2*br, size.height), 180f, -90f, false)
overflow = TextOverflow.Ellipsis 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)
if (!subtitle.isNullOrBlank()) { lineTo(size.width, 0f)
Spacer(modifier = Modifier.height(2.dp)) }
Text( drawPath(uPath, brush, style = Stroke(strokeWidth))
text = subtitle, } else {
color = PurrfectPalette.textSecondary, borderPath.reset()
fontSize = 12.sp, borderPath.apply {
maxLines = 2, moveTo(tr, 0f)
overflow = TextOverflow.Ellipsis 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)
Box(contentAlignment = Alignment.CenterEnd) { 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( Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = morphingParams.internalTopPadding)
.padding(horizontal = 16.dp, vertical = morphingParams.internalVerticalPadding)
.height(morphingParams.headerHeight),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)
content = actions ) {
) 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()
}
}
} }
} }
} }

View File

@@ -14,8 +14,8 @@ import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures 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.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog 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.core.net.toUri
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
@@ -53,6 +56,7 @@ import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest import coil.request.ImageRequest
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.bridge.DownloadCallback import me.eternal.purrfectsnap.bridge.DownloadCallback
import me.eternal.purrfectsnap.common.data.download.DownloadMetadata 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.download.FFMpegProcessor
import me.eternal.purrfectsnap.task.* import me.eternal.purrfectsnap.task.*
import me.eternal.purrfectsnap.ui.manager.Routes 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.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.OnLifecycleEvent import me.eternal.purrfectsnap.ui.util.OnLifecycleEvent
import me.eternal.purrfectsnap.ui.util.coil.cacheKey import me.eternal.purrfectsnap.ui.util.coil.cacheKey
@@ -74,17 +79,31 @@ import kotlin.math.absoluteValue
import kotlin.text.Regex import kotlin.text.Regex
class TasksRootSection : Routes.Route() { class TasksRootSection : Routes.Route() {
private var activeTasks by mutableStateOf(listOf<PendingTask>()) internal var activeTasks by mutableStateOf(listOf<PendingTask>())
private lateinit var recentTasks: MutableList<Task> internal lateinit var recentTasks: MutableList<Task>
private val taskSelection = mutableStateListOf<Pair<Task, DocumentFile?>>() internal var lastFetchedTaskId: Long? by mutableStateOf(null)
internal val taskSelection = mutableStateListOf<Pair<Task, DocumentFile?>>()
private fun fetchActiveTasks(scope: CoroutineScope = context.coroutineScope) { internal fun isRecentTasksInitialized(): Boolean = ::recentTasks.isInitialized
internal fun fetchActiveTasks(scope: CoroutineScope = context.coroutineScope) {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
activeTasks = context.taskManager.getActiveTasks().values.sortedByDescending { it.taskId }.toMutableList() activeTasks = context.taskManager.getActiveTasks().values.sortedByDescending { it.taskId }.toMutableList()
} }
} }
private fun mergeSelection(selection: List<Pair<Task, DocumentFile>>) { 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<Pair<Task, DocumentFile>>) {
val firstTask = selection.first().first val firstTask = selection.first().first
val taskHash = UUID.randomUUID().toString().longHashCode().absoluteValue.toString(16) val taskHash = UUID.randomUUID().toString().longHashCode().absoluteValue.toString(16)
@@ -105,7 +124,6 @@ class TasksRootSection : Routes.Route() {
runCatching { runCatching {
pendingTask.updateProgress("Copying ${documentFile.name}") pendingTask.updateProgress("Copying ${documentFile.name}")
context.androidContext.contentResolver.openInputStream(documentFile.uri)?.use { inputStream -> context.androidContext.contentResolver.openInputStream(documentFile.uri)?.use { inputStream ->
//copy with progress
val length = documentFile.length().toFloat() val length = documentFile.length().toFloat()
tempFile.outputStream().use { outputStream -> tempFile.outputStream().use { outputStream ->
val buffer = ByteArray(16 * 1024) 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()) { if (taskSelection.isNotEmpty()) {
taskSelection.forEach { (task, documentFile) -> taskSelection.forEach { (task, documentFile) ->
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
@@ -189,7 +207,7 @@ class TasksRootSection : Routes.Route() {
} }
@Composable @Composable
private fun TaskDangerDialog( internal fun TaskDangerDialog(
visible: Boolean, visible: Boolean,
title: String, title: String,
message: String, message: String,
@@ -219,124 +237,33 @@ class TasksRootSection : Routes.Route() {
shadowElevation = 20.dp, shadowElevation = 20.dp,
border = BorderStroke(1.dp, borderGradient) border = BorderStroke(1.dp, borderGradient)
) { ) {
Box( Box(modifier = Modifier.background(PurrfectPalette.cardOverlay, dialogShape)) {
modifier = Modifier Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
.background(PurrfectPalette.cardOverlay, dialogShape) 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))) {
Column( 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) {
modifier = Modifier Icon(imageVector = Icons.Filled.Warning, contentDescription = null, modifier = Modifier.size(28.dp), tint = Color.White)
.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
)
} }
} }
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Column {
Text( Text(text = title, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = Color.White)
text = title, Text(text = message, fontSize = 13.sp, color = PurrfectPalette.textSecondary)
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.ExtraBold),
color = Color.White
)
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = PurrfectPalette.textSecondary
)
} }
} }
if (showDeleteFiles) { if (showDeleteFiles) {
Surface( 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) {
shape = RoundedCornerShape(18.dp), Text(text = translation["clear_tasks_delete_files"], fontSize = 14.sp, color = Color.White.copy(alpha = 0.9f))
color = Color.White.copy(alpha = 0.04f), Checkbox(checked = deleteFilesChecked, onCheckedChange = null, colors = CheckboxDefaults.colors(checkedColor = PurrfectPalette.glowPrimary, uncheckedColor = Color.White.copy(alpha = 0.3f), checkmarkColor = Color.White))
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( Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
modifier = Modifier.fillMaxWidth(), Button(onClick = { onDismiss() }, modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors(containerColor = Color.White.copy(alpha = 0.08f), contentColor = Color.White), shape = RoundedCornerShape(14.dp)) {
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End) Text(text = context.translation["button.cancel"])
) {
Button(
onClick = onDismiss,
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.08f),
contentColor = Color.White
)
) {
Text(context.translation["button.negative"])
} }
Button( Button(onClick = { onConfirm() }, modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color(0xFF1B152E)), shape = RoundedCornerShape(14.dp)) {
onClick = onConfirm, Text(text = context.translation["button.positive"], fontWeight = FontWeight.Bold)
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
contentColor = Color.White
)
) {
Text(context.translation["button.positive"])
} }
} }
} }
@@ -346,106 +273,23 @@ class TasksRootSection : Routes.Route() {
} }
@Composable @Composable
private fun TasksEmptyState(text: String) { internal fun TasksEmptyState(text: String) {
Column( Column(
modifier = Modifier modifier = Modifier.fillMaxWidth().padding(top = 60.dp),
.fillMaxWidth()
.padding(top = 60.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Surface( Surface(shape = CircleShape, color = Color.White.copy(alpha = 0.08f), border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) {
shape = CircleShape, 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) {
color = Color.White.copy(alpha = 0.08f), Icon(Icons.Filled.CheckCircle, contentDescription = text, tint = Color.White)
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 = text, style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.ExtraBold), color = Color.White)
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 }
)
} }
} }
@Composable @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 taskStatus by remember { mutableStateOf(task.status) }
var taskProgressLabel by remember { mutableStateOf<String?>(null) } var taskProgressLabel by remember { mutableStateOf<String?>(null) }
var taskProgress by remember { mutableIntStateOf(-1) } var taskProgress by remember { mutableIntStateOf(-1) }
@@ -460,32 +304,16 @@ class TasksRootSection : Routes.Route() {
} }
} }
val listener = remember { PendingTaskListener( val listener = remember { PendingTaskListener(
onStateChange = { onStateChange = { taskStatus = it },
taskStatus = it onProgress = { label, progress -> taskProgressLabel = label; taskProgress = progress }
},
onProgress = { label, progress ->
taskProgressLabel = label
taskProgress = progress
}
) } ) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) { pendingTask?.addListener(listener) }
pendingTask?.addListener(listener) DisposableEffect(Unit) { onDispose { pendingTask?.removeListener(listener) } }
}
DisposableEffect(Unit) {
onDispose {
pendingTask?.removeListener(listener)
}
}
fun toggleSelection() { fun toggleSelection() {
if (isSelected) { if (isSelected) { taskSelection.removeIf { it.first == task }; return }
taskSelection.removeIf { it.first == task }
return
}
taskSelection.add(task to documentFile) taskSelection.add(task to documentFile)
} }
@@ -506,27 +334,13 @@ class TasksRootSection : Routes.Route() {
val cardModifier = modifier val cardModifier = modifier
.pointerInput(Unit) { .pointerInput(Unit) {
detectTapGestures( detectTapGestures(
onTap = { onTap = { if (taskSelection.isNotEmpty()) toggleSelection() else openFile() },
if (taskSelection.isNotEmpty()) { onLongPress = { if (taskSelection.isNotEmpty()) openFile() else toggleSelection() }
toggleSelection()
return@detectTapGestures
}
openFile()
},
onLongPress = {
if (taskSelection.isNotEmpty()) {
openFile()
return@detectTapGestures
}
toggleSelection()
}
) )
} }
.let { .let {
if (isSelected) { if (isSelected) {
it it.border(2.dp, PurrfectPalette.glowSecondary, MaterialTheme.shapes.large).clip(MaterialTheme.shapes.large)
.border(2.dp, PurrfectPalette.glowSecondary, MaterialTheme.shapes.large)
.clip(MaterialTheme.shapes.large)
} else it } else it
} }
@@ -544,498 +358,65 @@ class TasksRootSection : Routes.Route() {
taskStatus == TaskStatus.CANCELLED -> Icons.Filled.Cancel taskStatus == TaskStatus.CANCELLED -> Icons.Filled.Cancel
else -> Icons.Filled.Info 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( Surface(
modifier = cardModifier, modifier = cardModifier.fillMaxWidth().padding(vertical = 4.dp),
shape = cardShape, shape = MaterialTheme.shapes.large,
color = Color.Transparent, color = Color.White.copy(alpha = 0.04f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)),
tonalElevation = 0.dp, tonalElevation = 0.dp,
shadowElevation = if (isSelected) 12.dp else 6.dp, shadowElevation = 0.dp
border = BorderStroke(
1.dp,
if (isSelected) Brush.linearGradient(listOf(PurrfectPalette.glowPrimary, PurrfectPalette.glowSecondary))
else SolidColor(Color.White.copy(alpha = 0.1f))
)
) { ) {
Row( Row(modifier = Modifier.padding(14.dp).fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
modifier = Modifier 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) {
.background(PurrfectPalette.cardOverlay, cardShape) 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))
.padding(12.dp), if (isActive) {
verticalAlignment = Alignment.CenterVertically CircularProgressIndicator(progress = { (taskProgress / 100f).coerceIn(0f, 1f) }, modifier = Modifier.fillMaxSize(), color = PurrfectPalette.glowPrimary, strokeWidth = 3.dp, trackColor = Color.Transparent, strokeCap = StrokeCap.Round)
) {
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
)
}
}
}
} }
} }
Column( Column(modifier = Modifier.weight(1f)) {
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)
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)
}
}
}
}
} }
if (chipLabel != null || chipIcon != null) {
Column { 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)
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 -> {}
}
}
}
} }
} }
} }
} }
override val content: @Composable (NavBackStackEntry) -> Unit = { override val init: () -> Unit = {
val scrollState = rememberLazyListState() recentTasks = mutableStateListOf()
val scope = rememberCoroutineScope() }
recentTasks = remember { mutableStateListOf() }
var lastFetchedTaskId by remember { mutableStateOf(null as Long?) } 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 showConfirmDialog by remember { mutableStateOf(false) }
var alsoDeleteFiles by remember { mutableStateOf(false) } val coroutineScope = rememberCoroutineScope()
fun fetchNewRecentTasks() { if (taskSelection.isNotEmpty()) {
scope.launch(Dispatchers.IO) { val hapticFeedback = LocalHapticFeedback.current
val tasks = context.taskManager.fetchStoredTasks(lastFetchedTaskId ?: Long.MAX_VALUE, limit = 20) if (taskSelection.size > 1) {
if (tasks.isNotEmpty()) { val canMergeSelection by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(taskSelection.size)) {
lastFetchedTaskId = tasks.keys.last() taskSelection.all { it.second?.type?.contains("video") == true }
val activeTaskIds = activeTasks.map { it.taskId } }
recentTasks.addAll(tasks.filter { it.key !in activeTaskIds }.values) 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"])
} }
} }
} 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"])
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()
}
}
}
}
} }
} }
if (showConfirmDialog) { if (showConfirmDialog) {
var alsoDeleteFiles by remember { mutableStateOf(false) }
val isSelection = taskSelection.isNotEmpty() val isSelection = taskSelection.isNotEmpty()
val titleText = if (isSelection) { val titleText = if (isSelection) {
translation.format("remove_selected_tasks_confirm", "count" to taskSelection.size.toString()) translation.format("remove_selected_tasks_confirm", "count" to taskSelection.size.toString())
@@ -1046,14 +427,14 @@ class TasksRootSection : Routes.Route() {
TaskDangerDialog( TaskDangerDialog(
visible = showConfirmDialog, visible = showConfirmDialog,
title = titleText, title = titleText ?: "",
message = messageText, message = messageText ?: "",
showDeleteFiles = isSelection, showDeleteFiles = isSelection,
deleteFilesChecked = alsoDeleteFiles, deleteFilesChecked = alsoDeleteFiles,
onToggleDeleteFiles = { alsoDeleteFiles = it }, onToggleDeleteFiles = { alsoDeleteFiles = it },
onConfirm = { onConfirm = {
showConfirmDialog = false showConfirmDialog = false
clearTasks(alsoDeleteFiles, scope) clearTasks(alsoDeleteFiles, coroutineScope)
}, },
onDismiss = { showConfirmDialog = false } onDismiss = { showConfirmDialog = false }
) )

View File

@@ -1,343 +1,126 @@
package me.eternal.purrfectsnap.ui.manager.pages.home package me.eternal.purrfectsnap.ui.manager.pages.home
import android.os.SystemClock
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable 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.interaction.MutableInteractionSource
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.material3.*
import androidx.compose.material3.Button import androidx.compose.runtime.*
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource 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.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight 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.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavBackStackEntry import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.delay
import me.eternal.purrfectsnap.R 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.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 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() { class HomeAbout : Routes.Route() {
override val content: @Composable (NavBackStackEntry) -> Unit = { override val translation by lazy { context.translation.getCategory("manager.navigation.home_about") }
val avenirNext = remember {
FontFamily(Font(R.font.avenir_next_medium, FontWeight.Medium)) 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) { LaunchedEffect(Unit) {
context.shortToast(translation["about_magic_toast"]) context.shortToast(translation["about_magic_toast"])
} }
Box( key(themeId) {
modifier = Modifier val currentTheme = ManagerTheme.fromId(themeId).theme
.fillMaxSize() with(currentTheme) {
.background(PurrfectPalette.backgroundGradient) this@HomeAbout.AboutScreen(nav)
) {
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 = "<RSR/>",
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))
} }
} }
} }
@Composable @Composable
private fun DeveloperCard( internal fun DeveloperCard(
name: String, name: String,
imageRes: Int, imageRes: Int,
avenirNext: FontFamily, avenirNext: FontFamily,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val cardShape = RoundedCornerShape(20.dp) val tapSource = remember { MutableInteractionSource() }
val imageRing = Brush.linearGradient( val tapTimeoutMs = 1500L
listOf( val tapCount = remember { mutableIntStateOf(0) }
PurrfectPalette.glowPrimary, val lastTapTime = remember { mutableLongStateOf(0L) }
PurrfectPalette.glowSecondary
)
)
Surface( Surface(
modifier = modifier, onClick = {
shape = cardShape, val now = SystemClock.elapsedRealtime()
color = Color.White.copy(alpha = 0.08f), if (now - lastTapTime.longValue > tapTimeoutMs) {
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)), 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, tonalElevation = 0.dp,
shadowElevation = 0.dp shadowElevation = 0.dp
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier.padding(14.dp),
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp) verticalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
Box( Surface(
modifier = Modifier modifier = Modifier.size(64.dp),
.size(82.dp) shape = CircleShape,
.clip(CircleShape) color = Color.Transparent,
.background(Color.White.copy(alpha = 0.1f)) border = BorderStroke(2.dp, Brush.linearGradient(listOf(PurrfectPalette.glowPrimary, PurrfectPalette.glowSecondary)))
.border(2.dp, imageRing, CircleShape)
) { ) {
Image( Image(
painter = painterResource(id = imageRes), painter = painterResource(id = imageRes),
contentDescription = name, contentDescription = name,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize().clip(CircleShape)
) )
} }
Text( PurrfectMarqueeText(
text = name, text = name,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color.White, color = Color.White,
fontFamily = avenirNext, style = TextStyle(
maxLines = 1, fontWeight = FontWeight.Bold,
overflow = TextOverflow.Ellipsis fontSize = 16.sp,
fontFamily = avenirNext
)
) )
} }
} }
} }
} }

View File

@@ -64,6 +64,7 @@ import me.eternal.purrfectsnap.LogReader
import me.eternal.purrfectsnap.common.logger.LogChannel import me.eternal.purrfectsnap.common.logger.LogChannel
import me.eternal.purrfectsnap.common.logger.LogLevel import me.eternal.purrfectsnap.common.logger.LogLevel
import me.eternal.purrfectsnap.ui.manager.Routes 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.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
import me.eternal.purrfectsnap.ui.util.pullrefresh.PullRefreshIndicator 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 import me.eternal.purrfectsnap.common.util.ktx.copyToClipboard
class HomeLogs : Routes.Route() { class HomeLogs : Routes.Route() {
private val logListState by lazy { LazyListState(0) } internal val logListState = LazyListState()
private lateinit var activityLauncherHelper: ActivityLauncherHelper internal lateinit var activityLauncherHelper: ActivityLauncherHelper
private val externalRefreshTick = mutableStateOf(0) internal val externalRefreshTick = mutableIntStateOf(0)
override val init: () -> Unit = { override val init: () -> Unit = {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!) activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
} }
private fun clearLogsAndReload() { internal fun clearLogsAndReload() {
context.coroutineScope.launch { context.coroutineScope.launch {
context.log.clearLogs() context.log.clearLogs()
withContext(Dispatchers.Main) { 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 -> activityLauncherHelper.saveFile("purrfectsnap-logs-${System.currentTimeMillis()}.zip", "application/zip") { uri ->
context.coroutineScope.launch { context.coroutineScope.launch {
context.shortToast(translation["saving_logs_toast"]) context.shortToast(translation["saving_logs_toast"])
@@ -107,118 +108,13 @@ class HomeLogs : Routes.Route() {
} }
override val topBarActions: @Composable (RowScope.() -> Unit) = {} override val topBarActions: @Composable (RowScope.() -> Unit) = {}
override val content: @Composable (NavBackStackEntry) -> Unit = { override val content: @Composable (NavBackStackEntry) -> Unit = { nav ->
val coroutineScope = rememberCoroutineScope() val themeId by produceState(initialValue = context.config.root.global.uiSettings.managerTheme.get()) {
val composeContext = LocalContext.current while (true) { delay(300); value = context.config.root.global.uiSettings.managerTheme.get() }
var logReader by remember { mutableStateOf<LogReader?>(null) }
val visibleLogs = remember { mutableStateListOf<LogLine>() }
val mainExecutor = remember { context.androidContext.mainExecutor }
var isRefreshing by remember { mutableStateOf(false) }
fun refreshLogs() {
coroutineScope.launch {
val readerResult = withContext(Dispatchers.IO) {
runCatching {
context.log.newReader { line ->
if (shouldHideLog(line)) return@newReader
mainExecutor.execute {
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)
)
} }
key(themeId) { with(ManagerTheme.fromId(themeId).theme) { this@HomeLogs.LogsScreen(nav) } }
} }
override val floatingActionButton: @Composable () -> Unit = { override val floatingActionButton: @Composable () -> Unit = {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
Column( Column(
@@ -271,20 +167,19 @@ class HomeLogs : Routes.Route() {
} }
@Composable @Composable
private fun LogsFloatingBar( internal fun LogsFloatingBar(
isRefreshing: Boolean, isRefreshing: Boolean,
onRefresh: () -> Unit, onRefresh: () -> Unit,
onExport: () -> Unit, onExport: () -> Unit,
onClear: () -> Unit onClear: () -> Unit
) { ) {
var showDropDown by remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) }
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
Surface( Surface(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp) .padding(horizontal = 14.dp, vertical = 12.dp)
.padding(top = topPadding), .padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()),
shape = RoundedCornerShape(28.dp), shape = RoundedCornerShape(26.dp),
color = Color.White.copy(alpha = 0.07f), color = Color.White.copy(alpha = 0.07f),
tonalElevation = 0.dp, tonalElevation = 0.dp,
shadowElevation = 0.dp, shadowElevation = 0.dp,
@@ -292,87 +187,93 @@ class HomeLogs : Routes.Route() {
1.dp, 1.dp,
Brush.linearGradient( Brush.linearGradient(
listOf( listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.6f), PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.45f) PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
) )
) )
) )
) { ) {
Column( Row(
modifier = Modifier.padding(horizontal = 18.dp, vertical = 16.dp), modifier = Modifier
verticalArrangement = Arrangement.spacedBy(12.dp) .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Row( IconButton(onClick = { routes.navController.popBackStack() }) {
verticalAlignment = Alignment.CenterVertically, Icon(
horizontalArrangement = Arrangement.spacedBy(10.dp) imageVector = Icons.AutoMirrored.Filled.ArrowBack,
) { contentDescription = translation["common.back"],
IconButton(onClick = { routes.navController.popBackStack() }) { tint = Color.White
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, tint = Color.White)
}
Text(
text = routeInfo.translatedKey?.value ?: translation["manager.routes.home_logs"],
color = PurrfectPalette.textPrimary,
fontSize = 18.sp,
fontWeight = FontWeight.ExtraBold
) )
} }
Row( Text(
verticalAlignment = Alignment.CenterVertically, text = translation["manager.routes.home_logs"] ?: "Logs",
horizontalArrangement = Arrangement.spacedBy(6.dp) color = Color.White,
) { fontWeight = FontWeight.ExtraBold,
if (isRefreshing) { fontSize = 18.sp
CircularProgressIndicator( )
modifier = Modifier.size(18.dp), }
strokeWidth = 2.dp, Row(
color = PurrfectPalette.glowSecondary 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) { DropdownMenu(
Icon(Icons.Filled.Refresh, contentDescription = "Refresh", tint = Color.White) expanded = showMenu,
} onDismissRequest = { showMenu = false },
Box { offset = DpOffset(0.dp, 8.dp),
IconButton(onClick = { showDropDown = true }) { containerColor = Color(0xFF161821),
Icon(Icons.Filled.MoreVert, contentDescription = null, tint = PurrfectPalette.glowSecondary) shape = RoundedCornerShape(14.dp),
} tonalElevation = 8.dp,
DropdownMenu( shadowElevation = 12.dp
expanded = showDropDown, ) {
onDismissRequest = { showDropDown = false }, DropdownMenuItem(
offset = DpOffset(0.dp, 8.dp), leadingIcon = {
containerColor = Color(0xFF161821), Icon(
tonalElevation = 8.dp, imageVector = Icons.Filled.Download,
shadowElevation = 12.dp, contentDescription = null,
shape = RoundedCornerShape(14.dp) tint = PurrfectPalette.glowPrimary
) {
DropdownMenuItem(
onClick = {
onClear()
showDropDown = false
},
leadingIcon = { Icon(Icons.Filled.DeleteSweep, contentDescription = null, tint = PurrfectPalette.glowPrimary) },
text = { Text(translation["clear_logs_button"], color = Color.White) },
colors = MenuDefaults.itemColors(
textColor = Color.White,
leadingIconColor = PurrfectPalette.glowPrimary
) )
) },
DropdownMenuItem( text = { Text(text = translation["export_button"] ?: "Export", color = Color.White) },
onClick = { onClick = {
onExport() onExport()
showDropDown = false showMenu = false
}, }
leadingIcon = { Icon(Icons.Filled.Download, contentDescription = null, tint = PurrfectPalette.glowSecondary) }, )
text = { Text(translation["export_logs_button"], color = Color.White) }, DropdownMenuItem(
colors = MenuDefaults.itemColors( leadingIcon = {
textColor = Color.White, Icon(
leadingIconColor = PurrfectPalette.glowSecondary 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 @Composable
private fun EmptyLogsState() { internal fun EmptyLogsState() {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -422,8 +323,7 @@ class HomeLogs : Routes.Route() {
} }
@Composable @Composable
private fun LogEntryCard(line: LogLine, composeContext: android.content.Context) { internal fun LogEntryCard(line: LogLine, composeContext: android.content.Context) {
// Normalize overly fragmented log text (some entries were rendered with one character per line)
val normalizedMessage = remember(line.message) { val normalizedMessage = remember(line.message) {
val cleaned = line.message.replace("\r", "") val cleaned = line.message.replace("\r", "")
val fragments = cleaned.lines() 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.DEBUG -> PurrfectPalette.glowSecondary
LogLevel.INFO, LogLevel.VERBOSE -> Color(0xFFA3F0C2) LogLevel.INFO, LogLevel.VERBOSE -> Color(0xFFA3F0C2)
LogLevel.WARN -> Color(0xFFFFD782) LogLevel.WARN -> Color(0xFFFFD782)
LogLevel.ERROR, LogLevel.ASSERT -> Color(0xFFFF9CAB) 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.DEBUG -> "Debug"
LogLevel.INFO -> "Info" LogLevel.INFO -> "Info"
LogLevel.VERBOSE -> "Verbose" LogLevel.VERBOSE -> "Verbose"
@@ -550,14 +450,14 @@ class HomeLogs : Routes.Route() {
LogLevel.ASSERT -> "Assert" LogLevel.ASSERT -> "Assert"
} }
private fun logLevelIcon(logLevel: LogLevel) = when (logLevel) { internal fun logLevelIcon(logLevel: LogLevel) = when (logLevel) {
LogLevel.DEBUG -> Icons.Outlined.BugReport LogLevel.DEBUG -> Icons.Outlined.BugReport
LogLevel.ERROR, LogLevel.ASSERT -> Icons.Outlined.Report LogLevel.ERROR, LogLevel.ASSERT -> Icons.Outlined.Report
LogLevel.INFO, LogLevel.VERBOSE -> Icons.Outlined.Info LogLevel.INFO, LogLevel.VERBOSE -> Icons.Outlined.Info
LogLevel.WARN -> Icons.Outlined.Warning LogLevel.WARN -> Icons.Outlined.Warning
} }
private fun shouldHideLog(line: LogLine): Boolean { internal fun shouldHideLog(line: LogLine): Boolean {
val message = line.message.lowercase() val message = line.message.lowercase()
val tag = line.tag.lowercase() val tag = line.tag.lowercase()
return message.startsWith("blocked ep") || return message.startsWith("blocked ep") ||

View File

@@ -82,6 +82,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector 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.layout.onGloballyPositioned
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.SpanStyle 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.getQuickTiles
import me.eternal.purrfectsnap.storage.setQuickTiles import me.eternal.purrfectsnap.storage.setQuickTiles
import me.eternal.purrfectsnap.ui.manager.Routes 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.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.manager.data.UpdateDownloader import me.eternal.purrfectsnap.ui.manager.data.UpdateDownloader
import me.eternal.purrfectsnap.ui.manager.data.Updater 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") } override val translation by lazy { context.translation.getCategory("manager.sections.home") }
companion object { 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 cardMargin = 10.dp
val pageBackgroundGradient = Brush.verticalGradient( val pageBackgroundGradient = Brush.verticalGradient(
listOf( listOf(
@@ -148,23 +153,23 @@ class HomeRootSection : Routes.Route() {
) )
} }
private val changelogClient by lazy { OkHttpClient() } internal val changelogClient by lazy { OkHttpClient() }
private val changelogStableUrl = "https://raw.githubusercontent.com/particle-box/PurrfectSnap/dev/changelogs-stable.txt" internal 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" internal 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 announcementsUrl = "https://raw.githubusercontent.com/particle-box/PurrfectSnap/dev/announcements.txt"
private val heroGradientColors = listOf( internal val heroGradientColors = listOf(
Color(0xFF5C4B99), Color(0xFF5C4B99),
Color(0xFF322B5E), Color(0xFF322B5E),
Color(0xFF1B1836) Color(0xFF1B1836)
) )
private val quickActionsGradientColors = listOf( internal val quickActionsGradientColors = listOf(
Color(0xFF241C3E), Color(0xFF241C3E),
Color(0xFF151127) Color(0xFF151127)
) )
private lateinit var activityLauncherHelper: ActivityLauncherHelper private lateinit var activityLauncherHelper: ActivityLauncherHelper
data class QaCard(val id: String, val name: String, val icon: ImageVector, val action: (Routes) -> Unit) 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<QaCard>() val list = mutableListOf<QaCard>()
EnumQuickActions.entries.forEach { q -> EnumQuickActions.entries.forEach { q ->
val name = context.translation["actions.${q.key}.name"] val name = context.translation["actions.${q.key}.name"]
@@ -176,7 +181,7 @@ class HomeRootSection : Routes.Route() {
} }
list list
} }
private val cards by lazy { internal val cards by lazy {
EnumQuickActions.entries.map { EnumQuickActions.entries.map {
(context.translation["actions.${it.key}.name"] to it.icon) to it.action (context.translation["actions.${it.key}.name"] to it.icon) to it.action
}.associate { }.associate {
@@ -191,7 +196,7 @@ class HomeRootSection : Routes.Route() {
} }
@Composable @Composable
private fun rememberPreferenceBool(key: String, default: Boolean = false): State<Boolean> { internal fun rememberPreferenceBool(key: String, default: Boolean = false): State<Boolean> {
val prefs = remember { context.sharedPreferences } val prefs = remember { context.sharedPreferences }
val state = remember { mutableStateOf(prefs.getBoolean(key, default)) } val state = remember { mutableStateOf(prefs.getBoolean(key, default)) }
DisposableEffect(prefs, key) { DisposableEffect(prefs, key) {
@@ -214,13 +219,17 @@ class HomeRootSection : Routes.Route() {
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
tint: Color = MaterialTheme.colorScheme.onSurfaceVariant, tint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
containerColor: Color = MaterialTheme.colorScheme.primary.copy(alpha = 0.08f), containerColor: Color = MaterialTheme.colorScheme.primary.copy(alpha = 0.08f),
haptic: HapticFeedback? = null,
) { ) {
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val clickModifier = if (onClick != null) { val clickModifier = if (onClick != null) {
Modifier.clickable( Modifier.clickable(
interactionSource = interactionSource, interactionSource = interactionSource,
indication = LocalIndication.current indication = LocalIndication.current
) { onClick() } ) {
haptic?.performHapticFeedback(HapticFeedbackType.LongPress)
onClick()
}
} else { } else {
Modifier Modifier
} }
@@ -244,7 +253,7 @@ class HomeRootSection : Routes.Route() {
} }
@Composable @Composable
private fun HeroBadge(text: String) { internal fun HeroBadge(text: String) {
Text( Text(
text = text, text = text,
color = Color.White, color = Color.White,
@@ -624,7 +633,7 @@ class HomeRootSection : Routes.Route() {
onClick = onManageClick, onClick = onManageClick,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)), border = BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)),
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White), 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) Icon(Icons.Filled.Settings, contentDescription = null, tint = Color.White)
Spacer(modifier = Modifier.width(6.dp)) 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 } val entry = cardEntries.firstOrNull { it.name == name }
return entry?.id ?: name return entry?.id ?: name
} }
@@ -704,13 +713,13 @@ class HomeRootSection : Routes.Route() {
val key = resolveTileKey(name) val key = resolveTileKey(name)
prefs.edit().putString("quick_tile_size_$key", "${w.coerceIn(1,3)}x${h.coerceIn(1,3)}").apply() 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 prefs = context.sharedPreferences
val key = resolveTileKey(name) val key = resolveTileKey(name)
prefs.edit().remove("quick_tile_size_$key").apply() prefs.edit().remove("quick_tile_size_$key").apply()
} }
private fun clearTileOffset(name: String) { internal fun clearTileOffset(name: String) {
val prefs = context.sharedPreferences val prefs = context.sharedPreferences
val key = resolveTileKey(name) val key = resolveTileKey(name)
prefs.edit().remove("quick_tile_offset_$key").apply() 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 = { nav ->
override val content: @Composable (NavBackStackEntry) -> Unit = { val themeId by produceState(
val avenirNext = remember { initialValue = context.config.root.global.uiSettings.managerTheme.get()
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<String?>(null) }
var changelogText by remember { mutableStateOf<String?>(null) }
var changelogVersion by remember { mutableStateOf<String?>(null) }
var showAnnouncementsDialog by remember { mutableStateOf(false) }
var announcementsLoading by remember { mutableStateOf(false) }
var announcementsError by remember { mutableStateOf<String?>(null) }
var announcementsText by remember { mutableStateOf<String?>(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)
) { ) {
Column( while (true) {
modifier = Modifier delay(300)
.fillMaxSize() value = context.config.root.global.uiSettings.managerTheme.get()
.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))
} }
} }
key(themeId) {
if (showChangelogDialog && latestUpdate != null) { with(ManagerTheme.fromId(themeId).theme) {
AestheticDialog( this@HomeRootSection.HomeScreen(nav)
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
)
} }
} }
}
private fun extractChangelogForVersion(raw: String, version: String): String { internal fun extractChangelogForVersion(raw: String, version: String): String {
val lines = raw.lines() val lines = raw.lines()
val headerRegex = Regex("^\\s*#+\\s*v?${Regex.escape(version)}\\b", RegexOption.IGNORE_CASE) val headerRegex = Regex("^\\s*#+\\s*v?${Regex.escape(version)}\\b", RegexOption.IGNORE_CASE)
val collected = mutableListOf<String>() val collected = mutableListOf<String>()

View File

@@ -34,8 +34,6 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog 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.content.edit
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.navigation.NavBackStackEntry import androidx.navigation.NavBackStackEntry
@@ -45,7 +43,9 @@ import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.common.action.EnumAction import me.eternal.purrfectsnap.common.action.EnumAction
import me.eternal.purrfectsnap.common.bridge.InternalFileHandleType import me.eternal.purrfectsnap.common.bridge.InternalFileHandleType
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState 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.storage.setAllScopeNotes
import me.eternal.purrfectsnap.task.UpdateCheckWorker import me.eternal.purrfectsnap.task.UpdateCheckWorker
import me.eternal.purrfectsnap.ui.manager.Routes 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.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.setup.Requirements import me.eternal.purrfectsnap.ui.setup.Requirements
@@ -68,10 +69,10 @@ import java.util.concurrent.TimeUnit
class HomeSettings : Routes.Route() { class HomeSettings : Routes.Route() {
override val translation by lazy { context.translation.getCategory("manager.sections.home_settings") } 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 val dialogs by lazy { AlertDialogs(context.translation) }
private fun scheduleUpdateCheck() { internal fun scheduleUpdateCheck() {
val workManager = WorkManager.getInstance(context.androidContext) val workManager = WorkManager.getInstance(context.androidContext)
val updateSettings = context.config.root.global.updateSettings val updateSettings = context.config.root.global.updateSettings
var configDirty = false var configDirty = false
@@ -128,11 +129,31 @@ class HomeSettings : Routes.Route() {
workManager.cancelUniqueWork("purrfectsnap_update_check") workManager.cancelUniqueWork("purrfectsnap_update_check")
} }
} }
override val init: () -> Unit = { override val init: () -> Unit = {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!) 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 @Composable
private fun RowTitle(title: String) { internal fun RowTitle(title: String) {
Text( Text(
text = title, text = title,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp), modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp),
@@ -141,8 +162,9 @@ class HomeSettings : Routes.Route() {
color = Color.White color = Color.White
) )
} }
@Composable @Composable
private fun PremiumPreferenceToggle( internal fun PremiumPreferenceToggle(
sharedPreferences: SharedPreferences, sharedPreferences: SharedPreferences,
key: String, key: String,
text: String, text: String,
@@ -214,7 +236,7 @@ class HomeSettings : Routes.Route() {
} }
@Composable @Composable
private fun PreferenceToggle(sharedPreferences: SharedPreferences, key: String, text: String) { internal fun PreferenceToggle(sharedPreferences: SharedPreferences, key: String, text: String) {
val realKey = "debug_$key" val realKey = "debug_$key"
var value by remember { mutableStateOf(sharedPreferences.getBoolean(realKey, false)) } var value by remember { mutableStateOf(sharedPreferences.getBoolean(realKey, false)) }
val hapticFeedback = LocalHapticFeedback.current val hapticFeedback = LocalHapticFeedback.current
@@ -246,7 +268,7 @@ class HomeSettings : Routes.Route() {
} }
@Composable @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 { var confirmationDialog by remember {
mutableStateOf(false) mutableStateOf(false)
} }
@@ -294,8 +316,9 @@ class HomeSettings : Routes.Route() {
} }
} }
} }
@Composable @Composable
private fun ShiftedRow( internal fun ShiftedRow(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start, horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
verticalAlignment: Alignment.Vertical = Alignment.Top, verticalAlignment: Alignment.Vertical = Alignment.Top,
@@ -308,660 +331,52 @@ class HomeSettings : Routes.Route() {
) { content(this) } ) { content(this) }
} }
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable
override val content: @Composable (NavBackStackEntry) -> Unit = { internal fun GlassCard(
val contextC = LocalContext.current modifier: Modifier = Modifier,
val scope = rememberCoroutineScope() content: @Composable ColumnScope.() -> Unit
val scrollState = rememberScrollState() ) {
val positiveLabel = context.translation["button.positive"] Surface(
val negativeLabel = context.translation["button.negative"] modifier = modifier,
val importLabel = context.translation["button.import"] shape = RoundedCornerShape(22.dp),
val sharedButtonColors = ButtonDefaults.buttonColors( color = Color.White.copy(alpha = 0.04f),
containerColor = Color.White.copy(alpha = 0.12f), tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)),
contentColor = Color.White 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( Column(
modifier = Modifier.fillMaxSize() modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
Spacer(modifier = Modifier.height(topPadding)) content()
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<Map<String, String>>(json, object : com.google.gson.reflect.TypeToken<Map<String, String>>() {}.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))
}
} }
} }
} }
}
@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)
}
}
} }

View File

@@ -155,6 +155,7 @@ class RetroGameScreen : Routes.Route() {
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
context.shortToast(routes.homeAbout.translation["about_magic_toast"])
resetGame() resetGame()
while (true) { while (true) {
delay(16) delay(16)

View File

@@ -30,7 +30,9 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.* import kotlinx.coroutines.*
import me.eternal.purrfectsnap.common.scripting.type.ModuleInfo import me.eternal.purrfectsnap.common.scripting.type.ModuleInfo
import me.eternal.purrfectsnap.common.scripting.ui.EnumScriptInterface 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.isScriptEnabled
import me.eternal.purrfectsnap.storage.setScriptEnabled import me.eternal.purrfectsnap.storage.setScriptEnabled
import me.eternal.purrfectsnap.ui.manager.Routes 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.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.components.AestheticEmptyState import me.eternal.purrfectsnap.ui.manager.components.AestheticEmptyState
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper 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.chooseFolder
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
import me.eternal.purrfectsnap.ui.util.pullrefresh.PullRefreshIndicator import me.eternal.purrfectsnap.ui.util.pullrefresh.PullRefreshIndicator
@@ -57,15 +59,15 @@ import me.eternal.purrfectsnap.ui.util.pullrefresh.rememberPullRefreshState
class ScriptingRootSection : Routes.Route() { class ScriptingRootSection : Routes.Route() {
override val translation by lazy { context.translation.getCategory("manager.scripting") } override val translation by lazy { context.translation.getCategory("manager.scripting") }
private lateinit var activityLauncherHelper: ActivityLauncherHelper internal lateinit var activityLauncherHelper: ActivityLauncherHelper
val reloadDispatcher = AsyncUpdateDispatcher(updateOnFirstComposition = false) internal val reloadDispatcher = AsyncUpdateDispatcher(updateOnFirstComposition = false)
private var selectedTab by mutableStateOf(0) internal var selectedTab by mutableStateOf(0)
override val init: () -> Unit = { override val init: () -> Unit = {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!) activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
} }
suspend fun isScriptInstalledByUrl(scriptUrl: String): Boolean { internal suspend fun isScriptInstalledByUrl(scriptUrl: String): Boolean {
return try { return try {
val installedScripts = context.scriptManager.getSyncedModules() val installedScripts = context.scriptManager.getSyncedModules()
installedScripts.any { module -> 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 { context.coroutineScope.launch {
if (isScriptInstalledByUrl(scriptUrl)) { if (isScriptInstalledByUrl(scriptUrl)) {
context.shortToast(translation["script_already_installed"]) context.shortToast(translation["script_already_installed"])
@@ -97,7 +99,7 @@ class ScriptingRootSection : Routes.Route() {
} }
@Composable @Composable
private fun ImportRemoteScript( internal fun ImportRemoteScript(
dismiss: () -> Unit dismiss: () -> Unit
) { ) {
var url by remember { mutableStateOf("") } var url by remember { mutableStateOf("") }
@@ -172,7 +174,7 @@ class ScriptingRootSection : Routes.Route() {
} }
@Composable @Composable
private fun ModuleActions( internal fun ModuleActions(
script: ModuleInfo, script: ModuleInfo,
canUpdate: Boolean, canUpdate: Boolean,
dismiss: () -> Unit dismiss: () -> Unit
@@ -270,7 +272,7 @@ class ScriptingRootSection : Routes.Route() {
} }
@Composable @Composable
fun ModuleItem(script: ModuleInfo) { internal fun ModuleItem(script: ModuleInfo) {
var enabled by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(script)) { var enabled by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(script)) {
context.database.isScriptEnabled(script.name) context.database.isScriptEnabled(script.name)
} }
@@ -430,7 +432,7 @@ class ScriptingRootSection : Routes.Route() {
override val floatingActionButton: @Composable () -> Unit = {} override val floatingActionButton: @Composable () -> Unit = {}
@Composable @Composable
private fun SelectFolderButton(onClick: () -> Unit) { internal fun SelectFolderButton(onClick: () -> Unit) {
val label = translation["select_folder_button"] val label = translation["select_folder_button"]
Box( Box(
modifier = Modifier modifier = Modifier
@@ -483,7 +485,7 @@ class ScriptingRootSection : Routes.Route() {
} }
@Composable @Composable
fun ScriptSettings(script: ModuleInfo) { internal fun ScriptSettings(script: ModuleInfo) {
val settingsInterface = remember { val settingsInterface = remember {
val module = val module =
context.scriptManager.runtime.getModuleByName(script.name) ?: return@remember null 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 = { override val content: @Composable (NavBackStackEntry) -> Unit = { nav ->
val scriptingFolder by rememberAsyncMutableState( val themeId by produceState(initialValue = context.config.root.global.uiSettings.managerTheme.get()) {
defaultValue = null, while (true) {
updateDispatcher = reloadDispatcher delay(300)
) { context.scriptManager.getScriptsFolder() } value = context.config.root.global.uiSettings.managerTheme.get()
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) { key(themeId) {
ImportRemoteScript { showImportDialog = false } with(ManagerTheme.fromId(themeId).theme) {
} this@ScriptingRootSection.ScriptingScreen(nav)
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 @Composable
private fun InstalledTabContent( internal fun InstalledTabContent(
scriptingFolder: DocumentFile? scriptingFolder: DocumentFile?
) { ) {
val scriptModules by rememberAsyncMutableState( val scriptModules by rememberAsyncMutableState(
@@ -800,7 +741,7 @@ class ScriptingRootSection : Routes.Route() {
} }
@Composable @Composable
private fun CatalogTabContent( internal fun CatalogTabContent(
scriptingFolder: DocumentFile? scriptingFolder: DocumentFile?
) { ) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
@@ -833,7 +774,7 @@ class ScriptingRootSection : Routes.Route() {
} }
@Composable @Composable
private fun ScriptingHeader( internal fun ScriptingHeader(
titles: List<String>, titles: List<String>,
selectedTab: Int, selectedTab: Int,
onTabSelected: (Int) -> Unit, onTabSelected: (Int) -> Unit,
@@ -911,7 +852,7 @@ class ScriptingRootSection : Routes.Route() {
} }
@Composable @Composable
private fun ScriptingTabSwitcher( internal fun ScriptingTabSwitcher(
titles: List<String>, titles: List<String>,
selectedTab: Int, selectedTab: Int,
onTabSelected: (Int) -> Unit onTabSelected: (Int) -> Unit

View File

@@ -35,6 +35,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavBackStackEntry import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.R import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.common.ReceiversConfig 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.common.util.snap.SnapWidgetBroadcastReceiverHelper
import me.eternal.purrfectsnap.storage.* import me.eternal.purrfectsnap.storage.*
import me.eternal.purrfectsnap.ui.manager.Routes 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.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.coil.BitmojiImage import me.eternal.purrfectsnap.ui.util.coil.BitmojiImage
class SocialRootSection : Routes.Route() { class SocialRootSection : Routes.Route() {
private var friendList: List<MessagingFriendInfo> by mutableStateOf(emptyList()) internal var friendList: List<MessagingFriendInfo> by mutableStateOf(emptyList())
private var groupList: List<MessagingGroupInfo> by mutableStateOf(emptyList()) internal var groupList: List<MessagingGroupInfo> by mutableStateOf(emptyList())
private fun updateScopeLists() { internal fun updateScopeLists() {
context.coroutineScope.launch { context.coroutineScope.launch {
friendList = context.database.getFriends(descOrder = true) friendList = context.database.getFriends(descOrder = true)
groupList = context.database.getGroups() groupList = context.database.getGroups()
} }
} }
private fun requestLatestSnapshot() { internal fun requestLatestSnapshot() {
runCatching { runCatching {
context.androidContext.sendBroadcast( context.androidContext.sendBroadcast(
SnapWidgetBroadcastReceiverHelper.create(ReceiversConfig.BRIDGE_SYNC_ACTION) {} SnapWidgetBroadcastReceiverHelper.create(ReceiversConfig.BRIDGE_SYNC_ACTION) {}
@@ -71,7 +73,7 @@ class SocialRootSection : Routes.Route() {
} }
@Composable @Composable
private fun ScopeList( internal fun ScopeList(
scope: SocialScope, scope: SocialScope,
friends: List<MessagingFriendInfo>, friends: List<MessagingFriendInfo>,
groups: List<MessagingGroupInfo> groups: List<MessagingGroupInfo>
@@ -88,7 +90,6 @@ class SocialRootSection : Routes.Route() {
contentPadding = PaddingValues(start = 10.dp, end = 10.dp, bottom = routes.bottomPadding + 12.dp), contentPadding = PaddingValues(start = 10.dp, end = 10.dp, bottom = routes.bottomPadding + 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp) verticalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
//check if scope list is empty
val listSize = list.size val listSize = list.size
if (listSize == 0) { if (listSize == 0) {
@@ -197,147 +198,23 @@ class SocialRootSection : Routes.Route() {
} }
} }
@OptIn(ExperimentalFoundationApi::class) override val content: @Composable (NavBackStackEntry) -> Unit = { nav ->
override val content: @Composable (NavBackStackEntry) -> Unit = { val themeId by produceState(initialValue = context.config.root.global.uiSettings.managerTheme.get()) {
val titles = remember { while (true) {
listOf(translation["friends_tab"], translation["groups_tab"]) delay(300)
} value = context.config.root.global.uiSettings.managerTheme.get()
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( key(themeId) {
modifier = Modifier with(ManagerTheme.fromId(themeId).theme) {
.fillMaxSize() this@SocialRootSection.SocialScreen(nav)
.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 @Composable
private fun SocialCard( internal fun SocialCard(
scope: SocialScope, scope: SocialScope,
friend: MessagingFriendInfo?, friend: MessagingFriendInfo?,
group: MessagingGroupInfo?, group: MessagingGroupInfo?,
@@ -500,7 +377,7 @@ class SocialRootSection : Routes.Route() {
} }
@Composable @Composable
private fun SocialHeader( internal fun SocialHeader(
titles: List<String>, titles: List<String>,
pagerState: androidx.compose.foundation.pager.PagerState, pagerState: androidx.compose.foundation.pager.PagerState,
onTabSelected: (Int) -> Unit, onTabSelected: (Int) -> Unit,
@@ -574,7 +451,7 @@ class SocialRootSection : Routes.Route() {
} }
@Composable @Composable
private fun SocialTabSwitcher( internal fun SocialTabSwitcher(
titles: List<String>, titles: List<String>,
pagerState: androidx.compose.foundation.pager.PagerState, pagerState: androidx.compose.foundation.pager.PagerState,
onTabSelected: (Int) -> Unit onTabSelected: (Int) -> Unit
@@ -620,7 +497,7 @@ class SocialRootSection : Routes.Route() {
} }
@Composable @Composable
private fun EmptyState(scope: SocialScope) { internal fun EmptyState(scope: SocialScope) {
val title = when (scope) { val title = when (scope) {
SocialScope.FRIEND -> translation.getOrNull("friends_empty_title") ?: translation["empty_hint"] SocialScope.FRIEND -> translation.getOrNull("friends_empty_title") ?: translation["empty_hint"]
SocialScope.GROUP -> translation.getOrNull("groups_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) modifier = Modifier.size(26.dp)
) )
Text( Text(
text = title, text = title ?: "",
color = Color.White, color = Color.White,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
fontSize = 15.sp fontSize = 15.sp
) )
Text( Text(
text = translation["social_empty_hint"], text = translation["social_empty_hint"] ?: "",
color = PurrfectPalette.textSecondary, color = PurrfectPalette.textSecondary,
fontSize = 12.sp fontSize = 12.sp
) )
@@ -662,7 +539,7 @@ class SocialRootSection : Routes.Route() {
} }
@Composable @Composable
private fun StatPill(label: String, value: Int) { internal fun StatPill(label: String, value: Int) {
Surface( Surface(
shape = RoundedCornerShape(50), shape = RoundedCornerShape(50),
color = Color.White.copy(alpha = 0.08f), color = Color.White.copy(alpha = 0.08f),

View File

@@ -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 = "<RSR/>", 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 }
)
}
}

View File

@@ -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<Any>, it.value as PropertyValue<Any>) }
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"
)
}
}
}

View File

@@ -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<String?>(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
)
}
}

View File

@@ -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<me.eternal.purrfectsnap.LogReader?>(null) }
val visibleLogs = remember { mutableStateListOf<me.eternal.purrfectsnap.LogLine>() }
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
)
)
}
}
}
)
}
}

View File

@@ -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
)
}
}
}

View File

@@ -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<Map<String, String>>(json, object : com.google.gson.reflect.TypeToken<Map<String, String>>() {}.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)
)
}
}
)
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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<String?>(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 -> {}
}
}
}
}
}
}

View File

@@ -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)
}
}

View File

@@ -25,13 +25,7 @@ import androidx.compose.material.icons.filled.FileOpen
import androidx.compose.material.icons.filled.Store import androidx.compose.material.icons.filled.Store
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavBackStackEntry import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList 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.common.util.snap.BitmojiSelfie
import me.eternal.purrfectsnap.storage.* import me.eternal.purrfectsnap.storage.*
import me.eternal.purrfectsnap.ui.manager.Routes 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.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper 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") } override val translation by lazy { context.translation.getCategory("manager.friend_tracker") }
private val titles by lazy { internal val titles by lazy {
listOf( listOf(
translation["rules_tab"], translation["rules_tab"],
translation["logs_tab"] translation["logs_tab"]
) )
} }
private var currentPage by mutableIntStateOf(0) internal var currentPage by mutableIntStateOf(0)
private lateinit var logDeleteAction : () -> Unit internal lateinit var logDeleteAction : () -> Unit
private lateinit var exportAction : () -> Unit internal lateinit var exportAction : () -> Unit
@Composable @Composable
private fun TrackerIconButton( internal fun TrackerIconButton(
icon: ImageVector, icon: ImageVector,
contentDescription: String?, contentDescription: String?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -114,7 +110,7 @@ class FriendTrackerManagerRoot : Routes.Route() {
} }
@Composable @Composable
private fun TrackerActionButton( internal fun TrackerActionButton(
label: String, label: String,
icon: ImageVector, icon: ImageVector,
onClick: () -> Unit, onClick: () -> Unit,
@@ -156,7 +152,7 @@ class FriendTrackerManagerRoot : Routes.Route() {
} }
@Composable @Composable
private fun TrackerPillButton( internal fun TrackerPillButton(
label: String, label: String,
icon: ImageVector, icon: ImageVector,
onClick: () -> Unit, onClick: () -> Unit,
@@ -304,7 +300,7 @@ class FriendTrackerManagerRoot : Routes.Route() {
} }
} }
private lateinit var activityLauncherHelper: ActivityLauncherHelper internal lateinit var activityLauncherHelper: ActivityLauncherHelper
override val init: () -> Unit = { override val init: () -> Unit = {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!) activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
@@ -347,7 +343,7 @@ class FriendTrackerManagerRoot : Routes.Route() {
} }
@Composable @Composable
private fun ConfigRulesTab() { internal fun ConfigRulesTab() {
val updateRules = rememberAsyncUpdateDispatcher() val updateRules = rememberAsyncUpdateDispatcher()
val rules = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = updateRules) { val rules = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = updateRules) {
context.database.getTrackerRulesDesc() context.database.getTrackerRulesDesc()
@@ -568,9 +564,9 @@ class FriendTrackerManagerRoot : Routes.Route() {
} }
} }
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
override val content: @Composable (NavBackStackEntry) -> Unit = { @Composable
internal fun TrackerScreenContent(nav: NavBackStackEntry) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState(initialPage = 0) { titles.size } val pagerState = rememberPagerState(initialPage = 0) { titles.size }
currentPage = pagerState.currentPage currentPage = pagerState.currentPage
@@ -625,47 +621,47 @@ class FriendTrackerManagerRoot : Routes.Route() {
) )
) )
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp), .padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween 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)) { TrackerPillButton(
Text( label = translation["import_button"],
text = context.translation["manager.routes.friend_tracker"], icon = Icons.Default.FolderOpen,
color = Color.White, onClick = { showImportDialog = true }
fontWeight = FontWeight.ExtraBold, )
fontSize = 20.sp TrackerPillButton(
) label = translation["export_button"],
Text( icon = Icons.Default.SaveAlt,
text = titles.getOrNull(pagerState.currentPage) ?: "", onClick = { showExportDialog = true }
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 }
)
}
}
} }
} }
}
}
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Surface( Surface(
modifier = Modifier 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 @Composable

View File

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

View File

@@ -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)
}
}

View File

@@ -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
)
}
}

View File

@@ -247,7 +247,7 @@
"about_tagline": "An Xposed Module meant to enhance your Snapchat experience!", "about_tagline": "An Xposed Module meant to enhance your Snapchat experience!",
"about_lead_developers_title": "Lead Developers", "about_lead_developers_title": "Lead Developers",
"about_story_title": "Our Story", "about_story_title": "Our Story",
"about_story": "PurrfectSnap was founded on 2nd of October, 2025, as a fork of SnapEnhance by ΞTΞRNAL with a vision to provide users the quality Snapchat experience they deserve. This app was just meant to be a minor update in the SnapEnhance repository, but it soon became a separate app wherein the contributors kept adding features. Then the developer <RSR/> joined the team, and this app soon became a huge success. 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 <RSR/> joined the team, and this app soon became a huge success.\n\nWe would also like to convey our huge thanks to rhunk, the lead developer of SnapEnhance, as without him, this app wouldn't even exist. We are immensely grateful to him.\n\nWe received much love and support and gained 1K+ downloads in just two days! We thank all users and contributors; without your support, we wouldn't have reached this place.\n\nLastly, we would like to thank all our admins, notably: CLASSIC GENIUS, Harry, Sujal, Zain & schrodingerspet, who were right there with us from the very beginning. We would also like to thank all testers, notably Leo & Toxic, who tested and reported bugs continuously. We are immensely grateful for your contribution.",
"about_thanks_title": "With love, PurrfectSnap Team", "about_thanks_title": "With love, PurrfectSnap Team",
"about_magic_toast": "Tap 5 times in this screen to see some magic 😉!", "about_magic_toast": "Tap 5 times in this screen to see some magic 😉!",
"github_button": "GitHub", "github_button": "GitHub",
@@ -264,6 +264,8 @@
"read_logs_failed_toast": "Failed to read logs!" "read_logs_failed_toast": "Failed to read logs!"
}, },
"home_settings": { "home_settings": {
"ui_theme_title": "UI Theme",
"settings_ui_theme": "Aphelion Theme",
"actions_title": "Actions", "actions_title": "Actions",
"message_logger_title": "Message Logger", "message_logger_title": "Message Logger",
"debug_title": "Debug", "debug_title": "Debug",

View File

@@ -70,6 +70,7 @@ class Global : ConfigContainer() {
inner class UISettings : ConfigContainer() { inner class UISettings : ConfigContainer() {
val hapticFeedback = boolean("haptic_feedback", true) val hapticFeedback = boolean("haptic_feedback", true)
val useSystemToasts = boolean("use_system_toasts", false) 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) } val updateSettings = container("update_settings", UpdateSettings()) { addFlags(ConfigFlag.HIDDEN) }