Introducing Aphelion Theme
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,10 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
@@ -69,12 +73,7 @@ import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.RadioButtonDefaults
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -116,28 +115,43 @@ import kotlin.math.sin
|
||||
androidx.compose.animation.ExperimentalAnimationApi::class
|
||||
)
|
||||
class Navigation(
|
||||
private val context: RemoteSideContext,
|
||||
internal val context: RemoteSideContext,
|
||||
private val navController: NavHostController,
|
||||
val routes: Routes = Routes(context).also { it.navController = navController }
|
||||
) {
|
||||
private val translation by lazy { context.translation.getCategory("manager.navigation") }
|
||||
var openBottomBarCustomization by mutableStateOf(false)
|
||||
var globalScrollOffset by mutableIntStateOf(0)
|
||||
|
||||
@Composable
|
||||
fun TopBar() {
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = remember(navBackStackEntry) { routes.getCurrentRoute(navBackStackEntry) }
|
||||
if (currentRoute?.routeInfo?.hasOwnTopBar == true) return
|
||||
|
||||
val shrinkThreshold = me.eternal.purrfectsnap.ui.util.Motion.HEADER_MORPH_THRESHOLD
|
||||
val isAphelion = context.config.root.global.uiSettings.managerTheme.get() == "APHELION"
|
||||
val focusFactor = if (isAphelion) (globalScrollOffset / shrinkThreshold).coerceIn(0f, 1f) else 0f
|
||||
val headerHeight = lerp(64.dp, 48.dp, focusFactor)
|
||||
|
||||
val canGoBack = remember(navBackStackEntry) {
|
||||
currentRoute?.let { !it.routeInfo.primary || it.routeInfo.childIds.contains(routes.currentDestination) } == true
|
||||
}
|
||||
val haptic = LocalHapticFeedback.current
|
||||
TopAppBar(
|
||||
modifier = Modifier.height(headerHeight),
|
||||
title = {
|
||||
currentRoute?.apply {
|
||||
title?.invoke() ?: routeInfo.translatedKey?.value?.let {
|
||||
Text(
|
||||
text = it,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.graphicsLayer {
|
||||
scaleX = 1f - (focusFactor * 0.05f)
|
||||
scaleY = 1f - (focusFactor * 0.05f)
|
||||
translationY = (-2 * focusFactor).dp.toPx()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -146,11 +160,20 @@ class Navigation(
|
||||
val backButtonAnimation by animateFloatAsState(if (canGoBack) 1f else 0f, label = "backButton")
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.graphicsLayer { alpha = backButtonAnimation }
|
||||
.graphicsLayer {
|
||||
alpha = backButtonAnimation
|
||||
scaleX = 1f - (focusFactor * 0.1f)
|
||||
scaleY = 1f - (focusFactor * 0.1f)
|
||||
}
|
||||
.width(lerp(0.dp, 48.dp, backButtonAnimation))
|
||||
.height(48.dp)
|
||||
) {
|
||||
IconButton(onClick = { if (canGoBack) navController.popBackStack() }) {
|
||||
IconButton(onClick = {
|
||||
if (canGoBack) {
|
||||
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
|
||||
navController.popBackStack()
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||
}
|
||||
}
|
||||
@@ -162,21 +185,34 @@ class Navigation(
|
||||
actions = {
|
||||
currentRoute?.topBarActions?.invoke(this)
|
||||
if (currentRoute?.routeInfo?.id == routes.settings.routeInfo.id) {
|
||||
IconButton(onClick = { openBottomBarCustomization = true }) {
|
||||
IconButton(onClick = {
|
||||
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
|
||||
openBottomBarCustomization = true
|
||||
}) {
|
||||
Icon(Icons.Filled.Tune, contentDescription = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FloatingBottomBar() {
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = remember(navBackStackEntry) { routes.getCurrentRoute(navBackStackEntry) }
|
||||
val availableRoutes = remember {
|
||||
listOf(routes.tasks, routes.features, routes.home, routes.social, routes.scripting, routes.friendTracker)
|
||||
}
|
||||
val availableRouteMap = remember(availableRoutes) { availableRoutes.associateBy { it.routeInfo.id } }
|
||||
|
||||
val shrinkThreshold = me.eternal.purrfectsnap.ui.util.Motion.HEADER_MORPH_THRESHOLD
|
||||
val isAphelion = context.config.root.global.uiSettings.managerTheme.get() == "APHELION"
|
||||
val focusFactor = if (isAphelion) (globalScrollOffset / shrinkThreshold).coerceIn(0f, 1f) else 0f
|
||||
val barHeight = lerp(82.dp, 64.dp, focusFactor)
|
||||
val labelAlpha = (1f - (focusFactor * 2.5f)).coerceIn(0f, 1f)
|
||||
val iconTranslationY = (10 * focusFactor).dp
|
||||
|
||||
val prefs = remember { context.sharedPreferences }
|
||||
val defaultOrder = remember { listOf("tasks", "features", "home", "social", "scripts") }
|
||||
fun loadSelected(): List<String> {
|
||||
@@ -233,9 +269,17 @@ class Navigation(
|
||||
val animatedBarWidth by animateDpAsState(targetValue = targetBarWidth ?: 0.dp, label = "barWidth")
|
||||
Surface(
|
||||
shape = barShape,
|
||||
color = Color.Transparent,
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)),
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.9f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.85f)
|
||||
)
|
||||
)
|
||||
),
|
||||
modifier = Modifier
|
||||
.then(if (targetBarWidth != null) Modifier.width(animatedBarWidth) else Modifier.fillMaxWidth())
|
||||
.drawBehind {
|
||||
@@ -263,7 +307,7 @@ class Navigation(
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(82.dp)
|
||||
.height(barHeight)
|
||||
.clip(barShape)
|
||||
.background(PurrfectPalette.cardOverlay)
|
||||
.border(BorderStroke(1.dp, barBorder), barShape)
|
||||
@@ -296,16 +340,16 @@ class Navigation(
|
||||
)
|
||||
}
|
||||
)
|
||||
Box(Modifier.fillMaxWidth().height(82.dp)) {
|
||||
Box(Modifier.fillMaxWidth().height(barHeight)) {
|
||||
var barWidthPx by remember { mutableStateOf(0f) }
|
||||
val itemCount = selectedRoutes.size.coerceAtLeast(1)
|
||||
val density = androidx.compose.ui.platform.LocalDensity.current
|
||||
val selectedIndex = remember(currentRoute, selectedRoutes) {
|
||||
val index = selectedRoutes.indexOf(currentRoute)
|
||||
if (index >= 0) index else null // indexOf returns -1 when not found, replace with null
|
||||
if (index >= 0) index else null
|
||||
}
|
||||
|
||||
selectedIndex?.let { // Null check
|
||||
selectedIndex?.let {
|
||||
val itemWidthPx =
|
||||
remember(barWidthPx, itemCount) { if (itemCount > 0) barWidthPx / itemCount else 0f }
|
||||
val offsetAnim = remember { Animatable(0f) }
|
||||
@@ -369,7 +413,7 @@ class Navigation(
|
||||
.fillMaxHeight()
|
||||
.width(indicatorWidth.coerceAtLeast(0.dp))
|
||||
.offset(x = offsetX)
|
||||
.padding(vertical = 10.dp, horizontal = 2.dp)
|
||||
.padding(vertical = lerp(10.dp, 8.dp, focusFactor), horizontal = 2.dp)
|
||||
.graphicsLayer { scaleX = scaleXAnim; scaleY = scaleYAnim }
|
||||
) {
|
||||
Box(
|
||||
@@ -430,7 +474,10 @@ class Navigation(
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(22.dp + 2.dp * selectionProgress)
|
||||
.graphicsLayer { alpha = 0.65f + 0.35f * selectionProgress }
|
||||
.graphicsLayer {
|
||||
alpha = 0.65f + 0.35f * selectionProgress
|
||||
translationY = iconTranslationY.toPx()
|
||||
}
|
||||
)
|
||||
},
|
||||
label = {
|
||||
@@ -441,11 +488,15 @@ class Navigation(
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White.copy(alpha = 0.6f + 0.4f * selectionProgress),
|
||||
color = Color.White.copy(alpha = (0.6f + 0.4f * selectionProgress) * labelAlpha),
|
||||
maxLines = if (isLong) 2 else 1,
|
||||
overflow = if (isLong) TextOverflow.Ellipsis else TextOverflow.Clip,
|
||||
softWrap = isLong,
|
||||
modifier = if (isLong) Modifier.widthIn(max = 90.dp).wrapContentWidth(Alignment.CenterHorizontally) else Modifier.wrapContentWidth(Alignment.CenterHorizontally)
|
||||
modifier = (if (isLong) Modifier.widthIn(max = 90.dp).wrapContentWidth(Alignment.CenterHorizontally) else Modifier.wrapContentWidth(Alignment.CenterHorizontally))
|
||||
.graphicsLayer {
|
||||
alpha = labelAlpha
|
||||
translationY = (-10 * focusFactor).dp.toPx()
|
||||
}
|
||||
)
|
||||
},
|
||||
selected = isSelected,
|
||||
@@ -456,7 +507,10 @@ class Navigation(
|
||||
unselectedTextColor = Color.White.copy(alpha = 0.72f),
|
||||
indicatorColor = Color.Transparent
|
||||
),
|
||||
onClick = { route.navigateReset() }
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
|
||||
route.navigateReset()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -545,7 +599,7 @@ class Navigation(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
} else {
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val hapticCustom = LocalHapticFeedback.current
|
||||
var draggingId by remember { mutableStateOf<String?>(null) }
|
||||
var dragDelta by remember { mutableStateOf(0f) }
|
||||
var dragStartIndex by remember { mutableStateOf(-1) }
|
||||
@@ -577,7 +631,7 @@ class Navigation(
|
||||
draggingId = id
|
||||
dragStartIndex = selectedTabIds.indexOf(id)
|
||||
dragDelta = 0f
|
||||
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
|
||||
hapticCustom.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
|
||||
},
|
||||
onDrag = { _: PointerInputChange, dragAmount ->
|
||||
dragDelta += dragAmount.y
|
||||
@@ -591,7 +645,7 @@ class Navigation(
|
||||
list.add(targetIndex, id)
|
||||
selectedTabIds = list
|
||||
saveSelected(selectedTabIds)
|
||||
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
|
||||
hapticCustom.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -663,7 +717,7 @@ class Navigation(
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
) {
|
||||
availableRoutes.forEach { route ->
|
||||
val id = route.routeInfo.id
|
||||
val already = selectedTabIds.contains(id)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,56 +1,50 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.unit.lerp
|
||||
import androidx.compose.ui.zIndex
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.PurrfectMarqueeText
|
||||
import me.eternal.purrfectsnap.ui.util.Motion
|
||||
|
||||
@Immutable
|
||||
data class FloatingTopBarColors(
|
||||
val container: Color,
|
||||
val border: Brush
|
||||
val borderStart: Color,
|
||||
val borderEnd: Color
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun rememberDefaultFloatingTopBarColors(): FloatingTopBarColors {
|
||||
return remember {
|
||||
FloatingTopBarColors(
|
||||
container = Color.White.copy(alpha = 0.07f),
|
||||
border = Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
container = Color.White.copy(alpha = 0.12f),
|
||||
borderStart = PurrfectPalette.glowPrimary.copy(alpha = 0.6f),
|
||||
borderEnd = PurrfectPalette.glowSecondary.copy(alpha = 0.4f)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -61,65 +55,216 @@ fun FloatingTopBar(
|
||||
subtitle: String? = null,
|
||||
onBack: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
scrollOffset: Int = 0,
|
||||
containerAlpha: Float = 1f,
|
||||
titleAlignment: Alignment.Horizontal = Alignment.Start,
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
colors: FloatingTopBarColors = rememberDefaultFloatingTopBarColors()
|
||||
) {
|
||||
val shape = RoundedCornerShape(26.dp)
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp)
|
||||
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()),
|
||||
shape = shape,
|
||||
color = colors.container,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, colors.border)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
|
||||
val focusFactor by remember(scrollOffset) {
|
||||
derivedStateOf { (scrollOffset.toFloat() / Motion.HEADER_MORPH_THRESHOLD).coerceIn(0f, 1f) }
|
||||
}
|
||||
|
||||
val morphingParams by remember(focusFactor, statusBarHeight) {
|
||||
derivedStateOf {
|
||||
object {
|
||||
val headerHeight = lerp(64.dp, 56.dp, focusFactor)
|
||||
val sidePadding = lerp(14.dp, 0.dp, focusFactor)
|
||||
val containerTopPadding = lerp(statusBarHeight + 4.dp, 0.dp, focusFactor)
|
||||
val internalTopPadding = lerp(0.dp, statusBarHeight, focusFactor)
|
||||
val internalVerticalPadding = lerp(8.dp, 0.dp, focusFactor)
|
||||
val topCorners = lerp(26.dp, 0.dp, focusFactor)
|
||||
val bottomCorners = lerp(26.dp, 28.dp, focusFactor)
|
||||
val subtitleAlpha = (1f - (focusFactor * 2.5f)).coerceIn(0f, 1f)
|
||||
val subtitleTranslationY = lerp(0.dp, (-10).dp, focusFactor)
|
||||
val iconScale = 1f - (0.12f * focusFactor)
|
||||
val horizontalShift = (6 * focusFactor).dp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var hasSnapped by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(focusFactor) {
|
||||
if (focusFactor >= 1f && !hasSnapped) {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
hasSnapped = true
|
||||
} else if (focusFactor < 0.9f) {
|
||||
hasSnapped = false
|
||||
}
|
||||
}
|
||||
|
||||
val shape = remember(morphingParams.topCorners, morphingParams.bottomCorners) {
|
||||
RoundedCornerShape(
|
||||
topStart = morphingParams.topCorners,
|
||||
topEnd = morphingParams.topCorners,
|
||||
bottomStart = morphingParams.bottomCorners,
|
||||
bottomEnd = morphingParams.bottomCorners
|
||||
)
|
||||
}
|
||||
|
||||
val borderPath = remember { Path() }
|
||||
val uPath = remember { Path() }
|
||||
val refractiveColor = remember { Color(0xFF241F52) }
|
||||
|
||||
Box(modifier = modifier.fillMaxWidth().zIndex(10f)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = morphingParams.sidePadding)
|
||||
.padding(top = morphingParams.containerTopPadding)
|
||||
.height(morphingParams.internalTopPadding + morphingParams.headerHeight + 32.dp)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0.0f to refractiveColor.copy(alpha = 0.95f * focusFactor),
|
||||
0.6f to refractiveColor.copy(alpha = 0.85f * focusFactor),
|
||||
1.0f to Color.Transparent
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = morphingParams.sidePadding)
|
||||
.padding(top = morphingParams.containerTopPadding)
|
||||
.graphicsLayer {
|
||||
alpha = containerAlpha
|
||||
},
|
||||
shape = shape,
|
||||
color = Color.Transparent,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = (4 * focusFactor).dp
|
||||
) {
|
||||
if (onBack != null) {
|
||||
IconButton(onClick = onBack, modifier = Modifier.size(42.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
listOf(
|
||||
Color(0xFF1B152E).copy(alpha = 0.85f + (0.1f * focusFactor)),
|
||||
refractiveColor.copy(alpha = 0.85f + (0.1f * focusFactor))
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(0.dp))
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (!subtitle.isNullOrBlank()) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = subtitle,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(contentAlignment = Alignment.CenterEnd) {
|
||||
.drawBehind {
|
||||
val strokeWidth = 1.dp.toPx()
|
||||
val brush = Brush.linearGradient(listOf(colors.borderStart, colors.borderEnd))
|
||||
val tr = morphingParams.topCorners.toPx()
|
||||
val br = morphingParams.bottomCorners.toPx()
|
||||
|
||||
if (focusFactor > 0.9f) {
|
||||
uPath.reset()
|
||||
uPath.apply {
|
||||
moveTo(0f, 0f)
|
||||
lineTo(0f, size.height - br)
|
||||
arcTo(androidx.compose.ui.geometry.Rect(0f, size.height - 2*br, 2*br, size.height), 180f, -90f, false)
|
||||
lineTo(size.width - br, size.height)
|
||||
arcTo(androidx.compose.ui.geometry.Rect(size.width - 2*br, size.height - 2*br, size.width, size.height), 90f, -90f, false)
|
||||
lineTo(size.width, 0f)
|
||||
}
|
||||
drawPath(uPath, brush, style = Stroke(strokeWidth))
|
||||
} else {
|
||||
borderPath.reset()
|
||||
borderPath.apply {
|
||||
moveTo(tr, 0f)
|
||||
lineTo(size.width - tr, 0f)
|
||||
arcTo(androidx.compose.ui.geometry.Rect(size.width - 2*tr, 0f, size.width, 2*tr), 270f, 90f, false)
|
||||
lineTo(size.width, size.height - br)
|
||||
arcTo(androidx.compose.ui.geometry.Rect(size.width - 2*br, size.height - 2*br, size.width, size.height), 0f, 90f, false)
|
||||
lineTo(br, size.height)
|
||||
arcTo(androidx.compose.ui.geometry.Rect(0f, size.height - 2*br, 2*br, size.height), 90f, 90f, false)
|
||||
lineTo(0f, tr)
|
||||
arcTo(androidx.compose.ui.geometry.Rect(0f, 0f, 2*tr, 2*tr), 180f, 90f, false)
|
||||
}
|
||||
drawPath(borderPath, brush, style = Stroke(strokeWidth))
|
||||
}
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = morphingParams.internalTopPadding)
|
||||
.padding(horizontal = 16.dp, vertical = morphingParams.internalVerticalPadding)
|
||||
.height(morphingParams.headerHeight),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
content = actions
|
||||
)
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (onBack != null) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onBack()
|
||||
},
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = morphingParams.iconScale
|
||||
scaleY = morphingParams.iconScale
|
||||
translationX = -morphingParams.horizontalShift.toPx()
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 2.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = titleAlignment
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 19.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = if (titleAlignment == Alignment.CenterHorizontally) TextAlign.Center else TextAlign.Start,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
if (!subtitle.isNullOrBlank() && morphingParams.subtitleAlpha > 0.01f) {
|
||||
PurrfectMarqueeText(
|
||||
text = subtitle,
|
||||
color = PurrfectPalette.textSecondary.copy(alpha = morphingParams.subtitleAlpha),
|
||||
style = TextStyle(fontSize = 13.sp),
|
||||
textAlign = if (titleAlignment == Alignment.CenterHorizontally) TextAlign.Center else TextAlign.Start,
|
||||
contentAlignment = if (titleAlignment == Alignment.CenterHorizontally) Alignment.Center else Alignment.CenterStart,
|
||||
enabled = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer {
|
||||
translationY = morphingParams.subtitleTranslationY.toPx()
|
||||
alpha = morphingParams.subtitleAlpha
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.wrapContentWidth()
|
||||
.graphicsLayer {
|
||||
scaleX = morphingParams.iconScale
|
||||
scaleY = morphingParams.iconScale
|
||||
if (onBack != null) {
|
||||
translationX = morphingParams.horizontalShift.toPx()
|
||||
}
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
actions()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
@@ -42,9 +42,12 @@ import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.lifecycle.Lifecycle
|
||||
@@ -53,6 +56,7 @@ import coil.compose.rememberAsyncImagePainter
|
||||
import coil.request.ImageRequest
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.bridge.DownloadCallback
|
||||
import me.eternal.purrfectsnap.common.data.download.DownloadMetadata
|
||||
@@ -65,6 +69,7 @@ import me.eternal.purrfectsnap.download.DownloadProcessor
|
||||
import me.eternal.purrfectsnap.download.FFMpegProcessor
|
||||
import me.eternal.purrfectsnap.task.*
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.OnLifecycleEvent
|
||||
import me.eternal.purrfectsnap.ui.util.coil.cacheKey
|
||||
@@ -74,17 +79,31 @@ import kotlin.math.absoluteValue
|
||||
import kotlin.text.Regex
|
||||
|
||||
class TasksRootSection : Routes.Route() {
|
||||
private var activeTasks by mutableStateOf(listOf<PendingTask>())
|
||||
private lateinit var recentTasks: MutableList<Task>
|
||||
private val taskSelection = mutableStateListOf<Pair<Task, DocumentFile?>>()
|
||||
internal var activeTasks by mutableStateOf(listOf<PendingTask>())
|
||||
internal lateinit var recentTasks: MutableList<Task>
|
||||
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) {
|
||||
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 taskHash = UUID.randomUUID().toString().longHashCode().absoluteValue.toString(16)
|
||||
@@ -105,7 +124,6 @@ class TasksRootSection : Routes.Route() {
|
||||
runCatching {
|
||||
pendingTask.updateProgress("Copying ${documentFile.name}")
|
||||
context.androidContext.contentResolver.openInputStream(documentFile.uri)?.use { inputStream ->
|
||||
//copy with progress
|
||||
val length = documentFile.length().toFloat()
|
||||
tempFile.outputStream().use { outputStream ->
|
||||
val buffer = ByteArray(16 * 1024)
|
||||
@@ -164,7 +182,7 @@ class TasksRootSection : Routes.Route() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearTasks(alsoDeleteFiles: Boolean, scope: CoroutineScope) {
|
||||
internal fun clearTasks(alsoDeleteFiles: Boolean, scope: CoroutineScope) {
|
||||
if (taskSelection.isNotEmpty()) {
|
||||
taskSelection.forEach { (task, documentFile) ->
|
||||
scope.launch(Dispatchers.IO) {
|
||||
@@ -189,7 +207,7 @@ class TasksRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TaskDangerDialog(
|
||||
internal fun TaskDangerDialog(
|
||||
visible: Boolean,
|
||||
title: String,
|
||||
message: String,
|
||||
@@ -219,124 +237,33 @@ class TasksRootSection : Routes.Route() {
|
||||
shadowElevation = 20.dp,
|
||||
border = BorderStroke(1.dp, borderGradient)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(PurrfectPalette.cardOverlay, dialogShape)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 20.dp, vertical = 18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(56.dp)
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.38f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.32f)
|
||||
)
|
||||
),
|
||||
CircleShape
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.DeleteOutline,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
Box(modifier = Modifier.background(PurrfectPalette.cardOverlay, dialogShape)) {
|
||||
Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Surface(shape = CircleShape, color = Color.White.copy(alpha = 0.08f), border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) {
|
||||
Box(modifier = Modifier.size(56.dp).background(Brush.linearGradient(listOf(PurrfectPalette.glowPrimary.copy(alpha = 0.38f), PurrfectPalette.glowSecondary.copy(alpha = 0.32f)))), contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = Icons.Filled.Warning, contentDescription = null, modifier = Modifier.size(28.dp), tint = Color.White)
|
||||
}
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.ExtraBold),
|
||||
color = Color.White
|
||||
)
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
Column {
|
||||
Text(text = title, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = Color.White)
|
||||
Text(text = message, fontSize = 13.sp, color = PurrfectPalette.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
if (showDeleteFiles) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onToggleDeleteFiles(!deleteFilesChecked) }
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Checkbox(
|
||||
checked = deleteFilesChecked,
|
||||
onCheckedChange = { onToggleDeleteFiles(it) },
|
||||
colors = CheckboxDefaults.colors(
|
||||
checkedColor = PurrfectPalette.glowPrimary,
|
||||
uncheckedColor = Color.White,
|
||||
checkmarkColor = Color.Black
|
||||
)
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
text = context.translation["manager.sections.tasks.delete_files_option"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
text = context.translation["manager.sections.tasks.delete_files_option_hint"]
|
||||
?: "Also remove downloaded files",
|
||||
color = PurrfectPalette.textSecondary,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).background(Color.White.copy(alpha = 0.04f)).clickable { onToggleDeleteFiles(!deleteFilesChecked) }.padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(text = translation["clear_tasks_delete_files"], fontSize = 14.sp, color = Color.White.copy(alpha = 0.9f))
|
||||
Checkbox(checked = deleteFilesChecked, onCheckedChange = null, colors = CheckboxDefaults.colors(checkedColor = PurrfectPalette.glowPrimary, uncheckedColor = Color.White.copy(alpha = 0.3f), checkmarkColor = Color.White))
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)
|
||||
) {
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White.copy(alpha = 0.08f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(context.translation["button.negative"])
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Button(onClick = { onDismiss() }, modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors(containerColor = Color.White.copy(alpha = 0.08f), contentColor = Color.White), shape = RoundedCornerShape(14.dp)) {
|
||||
Text(text = context.translation["button.cancel"])
|
||||
}
|
||||
Button(
|
||||
onClick = onConfirm,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(context.translation["button.positive"])
|
||||
Button(onClick = { onConfirm() }, modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color(0xFF1B152E)), shape = RoundedCornerShape(14.dp)) {
|
||||
Text(text = context.translation["button.positive"], fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -346,106 +273,23 @@ class TasksRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TasksEmptyState(text: String) {
|
||||
internal fun TasksEmptyState(text: String) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 60.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 60.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(58.dp)
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.32f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.28f)
|
||||
)
|
||||
),
|
||||
CircleShape
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.CheckCircle,
|
||||
contentDescription = text,
|
||||
tint = Color.White
|
||||
)
|
||||
Surface(shape = CircleShape, color = Color.White.copy(alpha = 0.08f), border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) {
|
||||
Box(modifier = Modifier.size(58.dp).background(Brush.linearGradient(listOf(PurrfectPalette.glowPrimary.copy(alpha = 0.32f), PurrfectPalette.glowSecondary.copy(alpha = 0.28f))), CircleShape), contentAlignment = Alignment.Center) {
|
||||
Icon(Icons.Filled.CheckCircle, contentDescription = text, tint = Color.White)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.ExtraBold),
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override val topBarActions: @Composable (RowScope.() -> Unit) = {
|
||||
var showConfirmDialog by remember { mutableStateOf(false) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
if (taskSelection.size > 1) {
|
||||
val canMergeSelection by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(taskSelection.size)) {
|
||||
taskSelection.all { it.second?.type?.contains("video") == true }
|
||||
}
|
||||
|
||||
if (canMergeSelection) {
|
||||
TopBarActionButton(
|
||||
onClick = {
|
||||
mergeSelection(taskSelection.toList().also {
|
||||
taskSelection.clear()
|
||||
}.map { it.first to it.second!! })
|
||||
},
|
||||
icon = Icons.Filled.Merge,
|
||||
text = translation["merge_button"]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(onClick = {
|
||||
showConfirmDialog = true
|
||||
}) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = translation["clear_button_description"])
|
||||
}
|
||||
|
||||
if (showConfirmDialog) {
|
||||
var alsoDeleteFiles by remember { mutableStateOf(false) }
|
||||
val isSelection = taskSelection.isNotEmpty()
|
||||
val titleText = if (isSelection) {
|
||||
translation.format("remove_selected_tasks_confirm", "count" to taskSelection.size.toString())
|
||||
} else {
|
||||
translation["remove_all_tasks_confirm"]
|
||||
}
|
||||
val messageText = if (isSelection) translation["remove_selected_tasks_title"] else translation["remove_all_tasks_title"]
|
||||
|
||||
TaskDangerDialog(
|
||||
visible = showConfirmDialog,
|
||||
title = titleText,
|
||||
message = messageText,
|
||||
showDeleteFiles = isSelection,
|
||||
deleteFilesChecked = alsoDeleteFiles,
|
||||
onToggleDeleteFiles = { alsoDeleteFiles = it },
|
||||
onConfirm = {
|
||||
showConfirmDialog = false
|
||||
clearTasks(alsoDeleteFiles, coroutineScope)
|
||||
},
|
||||
onDismiss = { showConfirmDialog = false }
|
||||
)
|
||||
Text(text = text, style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.ExtraBold), color = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TaskCard(modifier: Modifier, task: Task, pendingTask: PendingTask? = null) {
|
||||
internal fun TaskCard(modifier: Modifier, task: Task, pendingTask: PendingTask? = null) {
|
||||
var taskStatus by remember { mutableStateOf(task.status) }
|
||||
var taskProgressLabel by remember { mutableStateOf<String?>(null) }
|
||||
var taskProgress by remember { mutableIntStateOf(-1) }
|
||||
@@ -460,32 +304,16 @@ class TasksRootSection : Routes.Route() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val listener = remember { PendingTaskListener(
|
||||
onStateChange = {
|
||||
taskStatus = it
|
||||
},
|
||||
onProgress = { label, progress ->
|
||||
taskProgressLabel = label
|
||||
taskProgress = progress
|
||||
}
|
||||
onStateChange = { taskStatus = it },
|
||||
onProgress = { label, progress -> taskProgressLabel = label; taskProgress = progress }
|
||||
) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
pendingTask?.addListener(listener)
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
pendingTask?.removeListener(listener)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) { pendingTask?.addListener(listener) }
|
||||
DisposableEffect(Unit) { onDispose { pendingTask?.removeListener(listener) } }
|
||||
|
||||
fun toggleSelection() {
|
||||
if (isSelected) {
|
||||
taskSelection.removeIf { it.first == task }
|
||||
return
|
||||
}
|
||||
if (isSelected) { taskSelection.removeIf { it.first == task }; return }
|
||||
taskSelection.add(task to documentFile)
|
||||
}
|
||||
|
||||
@@ -506,27 +334,13 @@ class TasksRootSection : Routes.Route() {
|
||||
val cardModifier = modifier
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onTap = {
|
||||
if (taskSelection.isNotEmpty()) {
|
||||
toggleSelection()
|
||||
return@detectTapGestures
|
||||
}
|
||||
openFile()
|
||||
},
|
||||
onLongPress = {
|
||||
if (taskSelection.isNotEmpty()) {
|
||||
openFile()
|
||||
return@detectTapGestures
|
||||
}
|
||||
toggleSelection()
|
||||
}
|
||||
onTap = { if (taskSelection.isNotEmpty()) toggleSelection() else openFile() },
|
||||
onLongPress = { if (taskSelection.isNotEmpty()) openFile() else toggleSelection() }
|
||||
)
|
||||
}
|
||||
.let {
|
||||
if (isSelected) {
|
||||
it
|
||||
.border(2.dp, PurrfectPalette.glowSecondary, MaterialTheme.shapes.large)
|
||||
.clip(MaterialTheme.shapes.large)
|
||||
it.border(2.dp, PurrfectPalette.glowSecondary, MaterialTheme.shapes.large).clip(MaterialTheme.shapes.large)
|
||||
} else it
|
||||
}
|
||||
|
||||
@@ -544,498 +358,65 @@ class TasksRootSection : Routes.Route() {
|
||||
taskStatus == TaskStatus.CANCELLED -> Icons.Filled.Cancel
|
||||
else -> Icons.Filled.Info
|
||||
}
|
||||
val chipColors = when {
|
||||
isActive -> AssistChipDefaults.assistChipColors(
|
||||
containerColor = Color.White.copy(alpha = 0.08f),
|
||||
labelColor = Color.White
|
||||
)
|
||||
taskStatus == TaskStatus.SUCCESS -> AssistChipDefaults.assistChipColors()
|
||||
taskStatus == TaskStatus.FAILURE -> AssistChipDefaults.assistChipColors(
|
||||
containerColor = Color(0xFFFF6B9B).copy(alpha = 0.18f),
|
||||
labelColor = Color.White
|
||||
)
|
||||
taskStatus == TaskStatus.CANCELLED -> AssistChipDefaults.assistChipColors(
|
||||
containerColor = Color.White.copy(alpha = 0.06f),
|
||||
labelColor = PurrfectPalette.textSecondary
|
||||
)
|
||||
else -> AssistChipDefaults.assistChipColors()
|
||||
}
|
||||
val countdownText = if (isActive) {
|
||||
taskProgressLabel?.let { label ->
|
||||
Regex("""(\d+d\s+)?(\d+h\s+)?(\d+m\s+)?\d+s""").find(label)?.value?.trim() ?: label
|
||||
}
|
||||
} else null
|
||||
|
||||
val cardShape = RoundedCornerShape(22.dp)
|
||||
Surface(
|
||||
modifier = cardModifier,
|
||||
shape = cardShape,
|
||||
color = Color.Transparent,
|
||||
modifier = cardModifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = if (isSelected) 12.dp else 6.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
if (isSelected) Brush.linearGradient(listOf(PurrfectPalette.glowPrimary, PurrfectPalette.glowSecondary))
|
||||
else SolidColor(Color.White.copy(alpha = 0.1f))
|
||||
)
|
||||
shadowElevation = 0.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.background(PurrfectPalette.cardOverlay, cardShape)
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(end = 15.dp)
|
||||
.size(50.dp)
|
||||
.clipToBounds(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
var loadFailed by remember { mutableStateOf(false) }
|
||||
documentFile?.let {
|
||||
if (taskStatus.isFinalStage() && isDocumentFileReadable && !loadFailed && (documentFileMimeType.contains("image") || documentFileMimeType.contains("video"))) {
|
||||
Image(
|
||||
painter = rememberAsyncImagePainter(
|
||||
model = ImageRequest.Builder(context.androidContext)
|
||||
.data(it.uri)
|
||||
.cacheKey(it.uri.toString())
|
||||
.placeholder(ColorDrawable(PurrfectPalette.cardOverlayColor.toArgb()))
|
||||
.build(),
|
||||
imageLoader = context.imageLoader,
|
||||
onError = { loadFailed = true }
|
||||
),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.size(50.dp)
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
)
|
||||
} else {
|
||||
when {
|
||||
!isDocumentFileReadable -> Icon(Icons.Filled.DeleteOutline, contentDescription = "File not found")
|
||||
documentFileMimeType.contains("image") -> Icon(Icons.Filled.Image, contentDescription = "Image")
|
||||
documentFileMimeType.contains("video") -> Icon(Icons.Filled.Videocam, contentDescription = "Video")
|
||||
documentFileMimeType.contains("audio") -> Icon(Icons.Filled.MusicNote, contentDescription = "Audio")
|
||||
else -> Icon(Icons.Filled.FileCopy, contentDescription = "File")
|
||||
}
|
||||
}
|
||||
} ?: run {
|
||||
when (task.type) {
|
||||
TaskType.DOWNLOAD -> Icon(Icons.Filled.Download, contentDescription = "Download")
|
||||
TaskType.CHAT_ACTION -> Icon(Icons.Filled.ChatBubble, contentDescription = "Chat Action")
|
||||
TaskType.SCHEDULED_SEND -> {
|
||||
val isActive = !taskStatus.isFinalStage()
|
||||
val rotation = if (isActive) {
|
||||
val transition = rememberInfiniteTransition(label = "scheduled_send")
|
||||
transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 360f,
|
||||
animationSpec = infiniteRepeatable(animation = tween(1200, easing = LinearEasing)),
|
||||
label = "rotation"
|
||||
).value
|
||||
} else 0f
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(50.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (isActive) {
|
||||
Brush.linearGradient(
|
||||
colors = listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.25f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.22f)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
Brush.linearGradient(
|
||||
colors = listOf(
|
||||
Color.White.copy(alpha = 0.06f),
|
||||
Color.White.copy(alpha = 0.06f)
|
||||
)
|
||||
)
|
||||
}
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Schedule,
|
||||
contentDescription = "Scheduled Send",
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.rotate(rotation),
|
||||
tint = if (isActive) PurrfectPalette.glowSecondary else PurrfectPalette.textSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(modifier = Modifier.padding(14.dp).fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Box(modifier = Modifier.size(54.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.06f)).border(1.dp, Color.White.copy(alpha = 0.1f), CircleShape), contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = if (task.type == TaskType.DOWNLOAD) Icons.Default.Download else Icons.Default.Transform, contentDescription = null, tint = Color.White.copy(alpha = 0.6f), modifier = Modifier.size(24.dp))
|
||||
if (isActive) {
|
||||
CircularProgressIndicator(progress = { (taskProgress / 100f).coerceIn(0f, 1f) }, modifier = Modifier.fillMaxSize(), color = PurrfectPalette.glowPrimary, strokeWidth = 3.dp, trackColor = Color.Transparent, strokeCap = StrokeCap.Round)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
if (task.type == TaskType.SCHEDULED_SEND) {
|
||||
// Professional design for scheduled send tasks
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
// Feature title
|
||||
Text(
|
||||
context.translation.getOrNull("scheduled_send_title") ?: "Scheduled Snaps",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium
|
||||
)
|
||||
|
||||
// Scheduled time without icon
|
||||
Text(
|
||||
task.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
|
||||
color = Color.White
|
||||
)
|
||||
|
||||
// Recipients with icon
|
||||
task.author?.takeIf { it != "null" }?.let { recipients ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.People,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp).padding(top = 2.dp),
|
||||
tint = PurrfectPalette.textSecondary
|
||||
)
|
||||
Text(
|
||||
recipients,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White,
|
||||
lineHeight = 20.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(task.title, style = MaterialTheme.typography.bodyMedium, color = Color.White)
|
||||
task.author?.takeIf { it != "null" }?.let {
|
||||
Spacer(modifier = Modifier.width(5.dp))
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, color = PurrfectPalette.textSecondary)
|
||||
}
|
||||
}
|
||||
Text(task.hash, style = MaterialTheme.typography.labelSmall, color = PurrfectPalette.textSecondary)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.padding(top = 5.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(5.dp)
|
||||
) {
|
||||
chipLabel?.let { label ->
|
||||
val leadingIcon: (@Composable () -> Unit)? = if (isActive && task.type == TaskType.SCHEDULED_SEND) {
|
||||
{
|
||||
Icon(
|
||||
Icons.Filled.Timer,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
} else if (isActive) {
|
||||
countdownText?.let { countdown ->
|
||||
{
|
||||
Text(
|
||||
countdown,
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
} else chipIcon?.let { icon ->
|
||||
{ Icon(icon, contentDescription = null) }
|
||||
}
|
||||
val displayLabel = if (isActive && task.type == TaskType.SCHEDULED_SEND && countdownText != null) {
|
||||
val sendingText = translation.getOrNull("schedule_sending_in")?.replace("{time}", countdownText) ?: "Sending in $countdownText"
|
||||
sendingText
|
||||
} else {
|
||||
label
|
||||
}
|
||||
AssistChip(
|
||||
onClick = {},
|
||||
enabled = false,
|
||||
leadingIcon = leadingIcon,
|
||||
label = { Text(displayLabel) },
|
||||
colors = chipColors
|
||||
)
|
||||
}
|
||||
if (taskStatus.isFinalStage()) {
|
||||
if (taskStatus != TaskStatus.SUCCESS) {
|
||||
Text("$taskStatus", style = MaterialTheme.typography.bodySmall, color = PurrfectPalette.textSecondary)
|
||||
}
|
||||
} else {
|
||||
if (!isActive) {
|
||||
taskProgressLabel?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, color = Color.White)
|
||||
}
|
||||
}
|
||||
if (taskProgress != -1 && taskProgressLabel == null) {
|
||||
LinearProgressIndicator(
|
||||
progress = { taskProgress.toFloat() / 100f },
|
||||
strokeCap = StrokeCap.Round,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = PurrfectPalette.glowSecondary,
|
||||
trackColor = Color.White.copy(alpha = 0.12f)
|
||||
)
|
||||
}
|
||||
if (!isActive) {
|
||||
task.extra?.takeIf { it?.isNotEmpty() == true }?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, color = PurrfectPalette.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(text = task.title, fontSize = 15.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(text = taskProgressLabel ?: task.author ?: "", fontSize = 12.sp, color = PurrfectPalette.textSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
|
||||
Column {
|
||||
if (pendingTask != null && !taskStatus.isFinalStage()) {
|
||||
FilledIconButton(
|
||||
onClick = {
|
||||
runCatching {
|
||||
pendingTask.cancel()
|
||||
}.onFailure { throwable ->
|
||||
context.log.error("Failed to cancel task $pendingTask", throwable)
|
||||
}
|
||||
},
|
||||
colors = IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = Color(0xFFFF6B9B).copy(alpha = 0.35f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Icon(Icons.Filled.Close, contentDescription = "Cancel")
|
||||
}
|
||||
} else {
|
||||
if (taskStatus == TaskStatus.SUCCESS) {
|
||||
AnimatedVisibility(
|
||||
visible = true,
|
||||
enter = fadeIn(animationSpec = tween(250, easing = FastOutSlowInEasing)) + scaleIn(animationSpec = tween(300, easing = FastOutSlowInEasing)),
|
||||
exit = fadeOut(animationSpec = tween(150)) + scaleOut(targetScale = 0.5f, animationSpec = tween(150))
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(PurrfectPalette.glowPrimary.copy(alpha = 0.22f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(Icons.Filled.Check, contentDescription = "Success", tint = Color.White)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
when (taskStatus) {
|
||||
TaskStatus.FAILURE -> Icon(Icons.Filled.Error, contentDescription = "Failure", tint = Color(0xFFFF6B9B))
|
||||
TaskStatus.CANCELLED -> Icon(Icons.Filled.Cancel, contentDescription = "Cancelled", tint = Color(0xFFFF6B9B))
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (chipLabel != null || chipIcon != null) {
|
||||
AssistChip(onClick = {}, label = { chipLabel?.let { Text(it) } }, leadingIcon = { chipIcon?.let { Icon(it, null, modifier = Modifier.size(18.dp)) } }, colors = AssistChipDefaults.assistChipColors(labelColor = Color.White, leadingIconContentColor = Color.White), border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)), shape = CircleShape)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
||||
val scrollState = rememberLazyListState()
|
||||
val scope = rememberCoroutineScope()
|
||||
recentTasks = remember { mutableStateListOf() }
|
||||
var lastFetchedTaskId by remember { mutableStateOf(null as Long?) }
|
||||
override val init: () -> Unit = {
|
||||
recentTasks = mutableStateListOf()
|
||||
}
|
||||
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = { nav ->
|
||||
val themeId by produceState(initialValue = context.config.root.global.uiSettings.managerTheme.get()) {
|
||||
while (true) { delay(300); value = context.config.root.global.uiSettings.managerTheme.get() }
|
||||
}
|
||||
key(themeId) { with(ManagerTheme.fromId(themeId).theme) { this@TasksRootSection.TasksScreen(nav) } }
|
||||
}
|
||||
|
||||
override val topBarActions: @Composable RowScope.() -> Unit = {
|
||||
var showConfirmDialog by remember { mutableStateOf(false) }
|
||||
var alsoDeleteFiles by remember { mutableStateOf(false) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
fun fetchNewRecentTasks() {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val tasks = context.taskManager.fetchStoredTasks(lastFetchedTaskId ?: Long.MAX_VALUE, limit = 20)
|
||||
if (tasks.isNotEmpty()) {
|
||||
lastFetchedTaskId = tasks.keys.last()
|
||||
val activeTaskIds = activeTasks.map { it.taskId }
|
||||
recentTasks.addAll(tasks.filter { it.key !in activeTaskIds }.values)
|
||||
if (taskSelection.isNotEmpty()) {
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
if (taskSelection.size > 1) {
|
||||
val canMergeSelection by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(taskSelection.size)) {
|
||||
taskSelection.all { it.second?.type?.contains("video") == true }
|
||||
}
|
||||
if (canMergeSelection) {
|
||||
TopBarActionButton(onClick = { if (context.config.root.global.uiSettings.hapticFeedback.get()) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); mergeSelection(taskSelection.toList().also { taskSelection.clear() }.map { it.first to it.second!! }) }, icon = Icons.Filled.Merge, text = translation["merge_button"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
fetchActiveTasks(this)
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
taskSelection.clear()
|
||||
}
|
||||
}
|
||||
|
||||
OnLifecycleEvent { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
fetchActiveTasks(scope)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp)
|
||||
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()),
|
||||
shape = RoundedCornerShape(26.dp),
|
||||
color = Color.White.copy(alpha = 0.07f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text = context.translation["manager.routes.tasks"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
Text(
|
||||
text = if (activeTasks.isNotEmpty()) {
|
||||
translation.format(
|
||||
"summary_active",
|
||||
"active" to activeTasks.size.toString(),
|
||||
"recent" to recentTasks.size.toString()
|
||||
)
|
||||
} else {
|
||||
translation.format(
|
||||
"summary_idle",
|
||||
"recent" to recentTasks.size.toString()
|
||||
)
|
||||
},
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
if (taskSelection.size > 1 && taskSelection.all { it.second?.type?.contains("video") == true }) {
|
||||
Surface(
|
||||
onClick = {
|
||||
mergeSelection(
|
||||
taskSelection.toList().also { taskSelection.clear() }
|
||||
.map { it.first to it.second!! }
|
||||
)
|
||||
},
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(listOf(PurrfectPalette.glowPrimary, PurrfectPalette.glowSecondary))
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(Icons.Filled.Merge, contentDescription = translation["merge_button"], tint = Color.White)
|
||||
Text(translation["merge_button"], color = Color.White, fontWeight = FontWeight.SemiBold, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
Surface(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(Icons.Filled.PlaylistAddCheckCircle, contentDescription = null, tint = Color.White)
|
||||
Text(
|
||||
text = translation.format("running_count", "count" to activeTasks.size.toString()),
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { showConfirmDialog = true }) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = translation["clear_button_description"], tint = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp),
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
|
||||
) {
|
||||
LazyColumn(
|
||||
state = scrollState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
top = 0.dp,
|
||||
bottom = routes.bottomPadding + 16.dp
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
item {
|
||||
if (activeTasks.isEmpty() && recentTasks.isEmpty()) {
|
||||
TasksEmptyState(text = translation["no_tasks"])
|
||||
}
|
||||
}
|
||||
items(activeTasks, key = { it.taskId }) { pendingTask ->
|
||||
TaskCard(modifier = Modifier.fillMaxWidth(), pendingTask.task, pendingTask = pendingTask)
|
||||
}
|
||||
items(recentTasks, key = { it.hash }) { task ->
|
||||
TaskCard(modifier = Modifier.fillMaxWidth(), task)
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
LaunchedEffect(remember { derivedStateOf { scrollState.firstVisibleItemIndex } }) {
|
||||
fetchNewRecentTasks()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { if (context.config.root.global.uiSettings.hapticFeedback.get()) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); showConfirmDialog = true }) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = translation["clear_button_description"])
|
||||
}
|
||||
}
|
||||
|
||||
if (showConfirmDialog) {
|
||||
var alsoDeleteFiles by remember { mutableStateOf(false) }
|
||||
val isSelection = taskSelection.isNotEmpty()
|
||||
val titleText = if (isSelection) {
|
||||
translation.format("remove_selected_tasks_confirm", "count" to taskSelection.size.toString())
|
||||
@@ -1046,14 +427,14 @@ class TasksRootSection : Routes.Route() {
|
||||
|
||||
TaskDangerDialog(
|
||||
visible = showConfirmDialog,
|
||||
title = titleText,
|
||||
message = messageText,
|
||||
title = titleText ?: "",
|
||||
message = messageText ?: "",
|
||||
showDeleteFiles = isSelection,
|
||||
deleteFilesChecked = alsoDeleteFiles,
|
||||
onToggleDeleteFiles = { alsoDeleteFiles = it },
|
||||
onConfirm = {
|
||||
showConfirmDialog = false
|
||||
clearTasks(alsoDeleteFiles, scope)
|
||||
clearTasks(alsoDeleteFiles, coroutineScope)
|
||||
},
|
||||
onDismiss = { showConfirmDialog = false }
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,343 +1,126 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.home
|
||||
|
||||
import android.os.SystemClock
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.delay
|
||||
import me.eternal.purrfectsnap.R
|
||||
import me.eternal.purrfectsnap.common.util.ktx.openLink
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import android.os.SystemClock
|
||||
import me.eternal.purrfectsnap.ui.util.PurrfectMarqueeText
|
||||
import me.eternal.purrfectsnap.ui.util.scaleOnPress
|
||||
|
||||
class HomeAbout : Routes.Route() {
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
||||
val avenirNext = remember {
|
||||
FontFamily(Font(R.font.avenir_next_medium, FontWeight.Medium))
|
||||
override val translation by lazy { context.translation.getCategory("manager.navigation.home_about") }
|
||||
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = { nav ->
|
||||
val themeId by produceState(
|
||||
initialValue = context.config.root.global.uiSettings.managerTheme.get()
|
||||
) {
|
||||
while (true) {
|
||||
delay(300)
|
||||
value = context.config.root.global.uiSettings.managerTheme.get()
|
||||
}
|
||||
}
|
||||
val scrollState = rememberScrollState()
|
||||
val aboutStory = remember { translation["about_story"] }
|
||||
val pagePadding = 16.dp
|
||||
val bottomPadding = routes.bottomPadding +
|
||||
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
|
||||
24.dp
|
||||
val tapSource = remember { MutableInteractionSource() }
|
||||
val tapTimeoutMs = 1500L
|
||||
val tapCount = remember { mutableIntStateOf(0) }
|
||||
val lastTapTime = remember { mutableLongStateOf(0L) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
context.shortToast(translation["about_magic_toast"])
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
.padding(bottom = bottomPadding)
|
||||
) {
|
||||
FloatingTopBar(
|
||||
title = routeInfo.translatedKey?.value ?: translation["manager.routes.home_about"],
|
||||
onBack = { routes.navController.popBackStack() }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = pagePadding)
|
||||
.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(30.dp),
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(PurrfectPalette.panelGradient)
|
||||
.padding(horizontal = 22.dp, vertical = 20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = translation["about_title"],
|
||||
fontSize = 28.sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
color = PurrfectPalette.textPrimary,
|
||||
fontFamily = avenirNext,
|
||||
modifier = Modifier.clickable(
|
||||
interactionSource = tapSource,
|
||||
indication = null
|
||||
) {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (now - lastTapTime.longValue > tapTimeoutMs) {
|
||||
tapCount.intValue = 0
|
||||
}
|
||||
tapCount.intValue += 1
|
||||
lastTapTime.longValue = now
|
||||
if (tapCount.intValue >= 5) {
|
||||
tapCount.intValue = 0
|
||||
routes.retroGame.navigate()
|
||||
}
|
||||
}
|
||||
)
|
||||
Text(
|
||||
text = translation["about_tagline"],
|
||||
fontSize = 13.sp,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = translation["about_lead_developers_title"],
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
modifier = Modifier.padding(top = 10.dp)
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
DeveloperCard(
|
||||
name = "ΞTΞRNAL",
|
||||
imageRes = R.drawable.pfp_external,
|
||||
avenirNext = avenirNext,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
DeveloperCard(
|
||||
name = "<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))
|
||||
key(themeId) {
|
||||
val currentTheme = ManagerTheme.fromId(themeId).theme
|
||||
with(currentTheme) {
|
||||
this@HomeAbout.AboutScreen(nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeveloperCard(
|
||||
internal fun DeveloperCard(
|
||||
name: String,
|
||||
imageRes: Int,
|
||||
avenirNext: FontFamily,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val cardShape = RoundedCornerShape(20.dp)
|
||||
val imageRing = Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary,
|
||||
PurrfectPalette.glowSecondary
|
||||
)
|
||||
)
|
||||
val tapSource = remember { MutableInteractionSource() }
|
||||
val tapTimeoutMs = 1500L
|
||||
val tapCount = remember { mutableIntStateOf(0) }
|
||||
val lastTapTime = remember { mutableLongStateOf(0L) }
|
||||
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = cardShape,
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
|
||||
onClick = {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (now - lastTapTime.longValue > tapTimeoutMs) {
|
||||
tapCount.intValue = 0
|
||||
}
|
||||
tapCount.intValue += 1
|
||||
lastTapTime.longValue = now
|
||||
if (tapCount.intValue >= 5) {
|
||||
tapCount.intValue = 0
|
||||
routes.retroGame.navigate()
|
||||
}
|
||||
},
|
||||
modifier = modifier.scaleOnPress(tapSource),
|
||||
interactionSource = tapSource,
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
color = Color.White.copy(alpha = 0.06f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 16.dp),
|
||||
modifier = Modifier.padding(14.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(82.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White.copy(alpha = 0.1f))
|
||||
.border(2.dp, imageRing, CircleShape)
|
||||
Surface(
|
||||
modifier = Modifier.size(64.dp),
|
||||
shape = CircleShape,
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(2.dp, Brush.linearGradient(listOf(PurrfectPalette.glowPrimary, PurrfectPalette.glowSecondary)))
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = imageRes),
|
||||
contentDescription = name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
modifier = Modifier.fillMaxSize().clip(CircleShape)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
PurrfectMarqueeText(
|
||||
text = name,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
fontFamily = avenirNext,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
style = TextStyle(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp,
|
||||
fontFamily = avenirNext
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ import me.eternal.purrfectsnap.LogReader
|
||||
import me.eternal.purrfectsnap.common.logger.LogChannel
|
||||
import me.eternal.purrfectsnap.common.logger.LogLevel
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
|
||||
import me.eternal.purrfectsnap.ui.util.pullrefresh.PullRefreshIndicator
|
||||
@@ -73,14 +74,14 @@ import me.eternal.purrfectsnap.ui.util.saveFile
|
||||
import me.eternal.purrfectsnap.common.util.ktx.copyToClipboard
|
||||
|
||||
class HomeLogs : Routes.Route() {
|
||||
private val logListState by lazy { LazyListState(0) }
|
||||
private lateinit var activityLauncherHelper: ActivityLauncherHelper
|
||||
private val externalRefreshTick = mutableStateOf(0)
|
||||
internal val logListState = LazyListState()
|
||||
internal lateinit var activityLauncherHelper: ActivityLauncherHelper
|
||||
internal val externalRefreshTick = mutableIntStateOf(0)
|
||||
override val init: () -> Unit = {
|
||||
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
|
||||
}
|
||||
|
||||
private fun clearLogsAndReload() {
|
||||
internal fun clearLogsAndReload() {
|
||||
context.coroutineScope.launch {
|
||||
context.log.clearLogs()
|
||||
withContext(Dispatchers.Main) {
|
||||
@@ -89,7 +90,7 @@ class HomeLogs : Routes.Route() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun exportLogs() {
|
||||
internal fun exportLogs() {
|
||||
activityLauncherHelper.saveFile("purrfectsnap-logs-${System.currentTimeMillis()}.zip", "application/zip") { uri ->
|
||||
context.coroutineScope.launch {
|
||||
context.shortToast(translation["saving_logs_toast"])
|
||||
@@ -107,118 +108,13 @@ class HomeLogs : Routes.Route() {
|
||||
}
|
||||
|
||||
override val topBarActions: @Composable (RowScope.() -> Unit) = {}
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val composeContext = LocalContext.current
|
||||
var logReader by remember { mutableStateOf<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)
|
||||
)
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = { nav ->
|
||||
val themeId by produceState(initialValue = context.config.root.global.uiSettings.managerTheme.get()) {
|
||||
while (true) { delay(300); value = context.config.root.global.uiSettings.managerTheme.get() }
|
||||
}
|
||||
key(themeId) { with(ManagerTheme.fromId(themeId).theme) { this@HomeLogs.LogsScreen(nav) } }
|
||||
}
|
||||
|
||||
override val floatingActionButton: @Composable () -> Unit = {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
Column(
|
||||
@@ -271,20 +167,19 @@ class HomeLogs : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LogsFloatingBar(
|
||||
internal fun LogsFloatingBar(
|
||||
isRefreshing: Boolean,
|
||||
onRefresh: () -> Unit,
|
||||
onExport: () -> Unit,
|
||||
onClear: () -> Unit
|
||||
) {
|
||||
var showDropDown by remember { mutableStateOf(false) }
|
||||
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp)
|
||||
.padding(top = topPadding),
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()),
|
||||
shape = RoundedCornerShape(26.dp),
|
||||
color = Color.White.copy(alpha = 0.07f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
@@ -292,87 +187,93 @@ class HomeLogs : Routes.Route() {
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.6f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.45f)
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 18.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
IconButton(onClick = { routes.navController.popBackStack() }) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, tint = Color.White)
|
||||
}
|
||||
Text(
|
||||
text = routeInfo.translatedKey?.value ?: translation["manager.routes.home_logs"],
|
||||
color = PurrfectPalette.textPrimary,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.ExtraBold
|
||||
IconButton(onClick = { routes.navController.popBackStack() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = translation["common.back"],
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
if (isRefreshing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = PurrfectPalette.glowSecondary
|
||||
Text(
|
||||
text = translation["manager.routes.home_logs"] ?: "Logs",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
IconButton(onClick = onRefresh, enabled = !isRefreshing) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Refresh,
|
||||
contentDescription = translation["refresh_button_description"],
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
Box {
|
||||
IconButton(onClick = { showMenu = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.MoreVert,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onRefresh) {
|
||||
Icon(Icons.Filled.Refresh, contentDescription = "Refresh", tint = Color.White)
|
||||
}
|
||||
Box {
|
||||
IconButton(onClick = { showDropDown = true }) {
|
||||
Icon(Icons.Filled.MoreVert, contentDescription = null, tint = PurrfectPalette.glowSecondary)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showDropDown,
|
||||
onDismissRequest = { showDropDown = false },
|
||||
offset = DpOffset(0.dp, 8.dp),
|
||||
containerColor = Color(0xFF161821),
|
||||
tonalElevation = 8.dp,
|
||||
shadowElevation = 12.dp,
|
||||
shape = RoundedCornerShape(14.dp)
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onClear()
|
||||
showDropDown = false
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Filled.DeleteSweep, contentDescription = null, tint = PurrfectPalette.glowPrimary) },
|
||||
text = { Text(translation["clear_logs_button"], color = Color.White) },
|
||||
colors = MenuDefaults.itemColors(
|
||||
textColor = Color.White,
|
||||
leadingIconColor = PurrfectPalette.glowPrimary
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false },
|
||||
offset = DpOffset(0.dp, 8.dp),
|
||||
containerColor = Color(0xFF161821),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
tonalElevation = 8.dp,
|
||||
shadowElevation = 12.dp
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Download,
|
||||
contentDescription = null,
|
||||
tint = PurrfectPalette.glowPrimary
|
||||
)
|
||||
)
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onExport()
|
||||
showDropDown = false
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Filled.Download, contentDescription = null, tint = PurrfectPalette.glowSecondary) },
|
||||
text = { Text(translation["export_logs_button"], color = Color.White) },
|
||||
colors = MenuDefaults.itemColors(
|
||||
textColor = Color.White,
|
||||
leadingIconColor = PurrfectPalette.glowSecondary
|
||||
},
|
||||
text = { Text(text = translation["export_button"] ?: "Export", color = Color.White) },
|
||||
onClick = {
|
||||
onExport()
|
||||
showMenu = false
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.DeleteSweep,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFFFF9CAB)
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
text = { Text(text = translation["clear_button"] ?: "Clear", color = Color.White) },
|
||||
onClick = {
|
||||
onClear()
|
||||
showMenu = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -381,7 +282,7 @@ class HomeLogs : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyLogsState() {
|
||||
internal fun EmptyLogsState() {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -422,8 +323,7 @@ class HomeLogs : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LogEntryCard(line: LogLine, composeContext: android.content.Context) {
|
||||
// Normalize overly fragmented log text (some entries were rendered with one character per line)
|
||||
internal fun LogEntryCard(line: LogLine, composeContext: android.content.Context) {
|
||||
val normalizedMessage = remember(line.message) {
|
||||
val cleaned = line.message.replace("\r", "")
|
||||
val fragments = cleaned.lines()
|
||||
@@ -534,14 +434,14 @@ class HomeLogs : Routes.Route() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun logLevelColor(logLevel: LogLevel): Color = when (logLevel) {
|
||||
internal fun logLevelColor(logLevel: LogLevel): Color = when (logLevel) {
|
||||
LogLevel.DEBUG -> PurrfectPalette.glowSecondary
|
||||
LogLevel.INFO, LogLevel.VERBOSE -> Color(0xFFA3F0C2)
|
||||
LogLevel.WARN -> Color(0xFFFFD782)
|
||||
LogLevel.ERROR, LogLevel.ASSERT -> Color(0xFFFF9CAB)
|
||||
}
|
||||
|
||||
private fun logLevelLabel(logLevel: LogLevel): String = when (logLevel) {
|
||||
internal fun logLevelLabel(logLevel: LogLevel): String = when (logLevel) {
|
||||
LogLevel.DEBUG -> "Debug"
|
||||
LogLevel.INFO -> "Info"
|
||||
LogLevel.VERBOSE -> "Verbose"
|
||||
@@ -550,14 +450,14 @@ class HomeLogs : Routes.Route() {
|
||||
LogLevel.ASSERT -> "Assert"
|
||||
}
|
||||
|
||||
private fun logLevelIcon(logLevel: LogLevel) = when (logLevel) {
|
||||
internal fun logLevelIcon(logLevel: LogLevel) = when (logLevel) {
|
||||
LogLevel.DEBUG -> Icons.Outlined.BugReport
|
||||
LogLevel.ERROR, LogLevel.ASSERT -> Icons.Outlined.Report
|
||||
LogLevel.INFO, LogLevel.VERBOSE -> Icons.Outlined.Info
|
||||
LogLevel.WARN -> Icons.Outlined.Warning
|
||||
}
|
||||
|
||||
private fun shouldHideLog(line: LogLine): Boolean {
|
||||
internal fun shouldHideLog(line: LogLine): Boolean {
|
||||
val message = line.message.lowercase()
|
||||
val tag = line.tag.lowercase()
|
||||
return message.startsWith("blocked ep") ||
|
||||
|
||||
@@ -82,6 +82,8 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -92,6 +94,8 @@ import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedback
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
@@ -122,6 +126,7 @@ import me.eternal.purrfectsnap.common.util.ktx.openLink
|
||||
import me.eternal.purrfectsnap.storage.getQuickTiles
|
||||
import me.eternal.purrfectsnap.storage.setQuickTiles
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.manager.data.UpdateDownloader
|
||||
import me.eternal.purrfectsnap.ui.manager.data.Updater
|
||||
@@ -137,7 +142,7 @@ class HomeRootSection : Routes.Route() {
|
||||
override val translation by lazy { context.translation.getCategory("manager.sections.home") }
|
||||
|
||||
companion object {
|
||||
private const val QUICK_TILES_INITIALIZED_PREF = "quick_tiles_initialized"
|
||||
internal const val QUICK_TILES_INITIALIZED_PREF = "quick_tiles_initialized"
|
||||
val cardMargin = 10.dp
|
||||
val pageBackgroundGradient = Brush.verticalGradient(
|
||||
listOf(
|
||||
@@ -148,23 +153,23 @@ class HomeRootSection : Routes.Route() {
|
||||
)
|
||||
}
|
||||
|
||||
private val changelogClient by lazy { OkHttpClient() }
|
||||
private val changelogStableUrl = "https://raw.githubusercontent.com/particle-box/PurrfectSnap/dev/changelogs-stable.txt"
|
||||
private val changelogPrereleaseUrl = "https://raw.githubusercontent.com/particle-box/PurrfectSnap/dev/changelogs-prerelease.txt"
|
||||
private val announcementsUrl = "https://raw.githubusercontent.com/particle-box/PurrfectSnap/dev/announcements.txt"
|
||||
internal val changelogClient by lazy { OkHttpClient() }
|
||||
internal val changelogStableUrl = "https://raw.githubusercontent.com/particle-box/PurrfectSnap/dev/changelogs-stable.txt"
|
||||
internal val changelogPrereleaseUrl = "https://raw.githubusercontent.com/particle-box/PurrfectSnap/dev/changelogs-prerelease.txt"
|
||||
internal val announcementsUrl = "https://raw.githubusercontent.com/particle-box/PurrfectSnap/dev/announcements.txt"
|
||||
|
||||
private val heroGradientColors = listOf(
|
||||
internal val heroGradientColors = listOf(
|
||||
Color(0xFF5C4B99),
|
||||
Color(0xFF322B5E),
|
||||
Color(0xFF1B1836)
|
||||
)
|
||||
private val quickActionsGradientColors = listOf(
|
||||
internal val quickActionsGradientColors = listOf(
|
||||
Color(0xFF241C3E),
|
||||
Color(0xFF151127)
|
||||
)
|
||||
private lateinit var activityLauncherHelper: ActivityLauncherHelper
|
||||
data class QaCard(val id: String, val name: String, val icon: ImageVector, val action: (Routes) -> Unit)
|
||||
private val cardEntries by lazy {
|
||||
internal val cardEntries by lazy {
|
||||
val list = mutableListOf<QaCard>()
|
||||
EnumQuickActions.entries.forEach { q ->
|
||||
val name = context.translation["actions.${q.key}.name"]
|
||||
@@ -176,7 +181,7 @@ class HomeRootSection : Routes.Route() {
|
||||
}
|
||||
list
|
||||
}
|
||||
private val cards by lazy {
|
||||
internal val cards by lazy {
|
||||
EnumQuickActions.entries.map {
|
||||
(context.translation["actions.${it.key}.name"] to it.icon) to it.action
|
||||
}.associate {
|
||||
@@ -191,7 +196,7 @@ class HomeRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberPreferenceBool(key: String, default: Boolean = false): State<Boolean> {
|
||||
internal fun rememberPreferenceBool(key: String, default: Boolean = false): State<Boolean> {
|
||||
val prefs = remember { context.sharedPreferences }
|
||||
val state = remember { mutableStateOf(prefs.getBoolean(key, default)) }
|
||||
DisposableEffect(prefs, key) {
|
||||
@@ -214,13 +219,17 @@ class HomeRootSection : Routes.Route() {
|
||||
onClick: (() -> Unit)? = null,
|
||||
tint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
containerColor: Color = MaterialTheme.colorScheme.primary.copy(alpha = 0.08f),
|
||||
haptic: HapticFeedback? = null,
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val clickModifier = if (onClick != null) {
|
||||
Modifier.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = LocalIndication.current
|
||||
) { onClick() }
|
||||
) {
|
||||
haptic?.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onClick()
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
@@ -244,7 +253,7 @@ class HomeRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeroBadge(text: String) {
|
||||
internal fun HeroBadge(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
color = Color.White,
|
||||
@@ -624,7 +633,7 @@ class HomeRootSection : Routes.Route() {
|
||||
onClick = onManageClick,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)),
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White),
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||
modifier = Alignment.CenterHorizontally.let { Modifier.align(it) }
|
||||
) {
|
||||
Icon(Icons.Filled.Settings, contentDescription = null, tint = Color.White)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
@@ -686,7 +695,7 @@ class HomeRootSection : Routes.Route() {
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun resolveTileKey(name: String): String {
|
||||
internal fun resolveTileKey(name: String): String {
|
||||
val entry = cardEntries.firstOrNull { it.name == name }
|
||||
return entry?.id ?: name
|
||||
}
|
||||
@@ -704,13 +713,13 @@ class HomeRootSection : Routes.Route() {
|
||||
val key = resolveTileKey(name)
|
||||
prefs.edit().putString("quick_tile_size_$key", "${w.coerceIn(1,3)}x${h.coerceIn(1,3)}").apply()
|
||||
}
|
||||
private fun clearTileSpan(name: String) {
|
||||
internal fun clearTileSpan(name: String) {
|
||||
val prefs = context.sharedPreferences
|
||||
val key = resolveTileKey(name)
|
||||
prefs.edit().remove("quick_tile_size_$key").apply()
|
||||
}
|
||||
|
||||
private fun clearTileOffset(name: String) {
|
||||
internal fun clearTileOffset(name: String) {
|
||||
val prefs = context.sharedPreferences
|
||||
val key = resolveTileKey(name)
|
||||
prefs.edit().remove("quick_tile_offset_$key").apply()
|
||||
@@ -732,618 +741,23 @@ class HomeRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class, ExperimentalAnimationApi::class, ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
||||
val avenirNext = remember {
|
||||
FontFamily(Font(R.font.avenir_next_medium, FontWeight.Medium))
|
||||
}
|
||||
val prefs = remember { context.sharedPreferences }
|
||||
val allQuickTileNames = remember(cards) { cards.keys.map { it.first } }
|
||||
val selectedTiles = rememberAsyncMutableStateList(defaultValue = allQuickTileNames) {
|
||||
val storedTiles = context.database.getQuickTiles().filter { it.isNotBlank() }
|
||||
val hasInitializedQuickTiles = prefs.getBoolean(QUICK_TILES_INITIALIZED_PREF, false)
|
||||
when {
|
||||
storedTiles.isNotEmpty() -> {
|
||||
if (!hasInitializedQuickTiles) {
|
||||
prefs.edit().putBoolean(QUICK_TILES_INITIALIZED_PREF, true).apply()
|
||||
}
|
||||
storedTiles
|
||||
}
|
||||
hasInitializedQuickTiles -> storedTiles
|
||||
else -> {
|
||||
context.database.setQuickTiles(allQuickTileNames)
|
||||
prefs.edit().putBoolean(QUICK_TILES_INITIALIZED_PREF, true).apply()
|
||||
allQuickTileNames
|
||||
}
|
||||
}
|
||||
}
|
||||
val updateChannel = context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable"
|
||||
val channelLabel = if (updateChannel == "prerelease") translation["channel_label_prerelease"] else translation["channel_label_stable"]
|
||||
val latestUpdate by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(updateChannel)) {
|
||||
val channel = if (updateChannel == "prerelease") Channel.PRERELEASE else Channel.STABLE
|
||||
Updater.getLatestRelease(channel)
|
||||
}
|
||||
val changelogUrl = if (updateChannel == "prerelease") changelogPrereleaseUrl else changelogStableUrl
|
||||
val downloadState by UpdateDownloader.downloadState.collectAsState()
|
||||
val downloadProgress by UpdateDownloader.downloadProgress.collectAsState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val isPurrAuraActive by rememberPreferenceBool("debug_test_mode", true)
|
||||
var showChangelogDialog by remember { mutableStateOf(false) }
|
||||
var changelogLoading by remember { mutableStateOf(false) }
|
||||
var changelogError by remember { mutableStateOf<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)
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = { nav ->
|
||||
val themeId by produceState(
|
||||
initialValue = context.config.root.global.uiSettings.managerTheme.get()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
.padding(bottom = contentBottomPadding)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(WindowInsets.statusBars.asPaddingValues())
|
||||
.padding(horizontal = cardMargin, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TopBarActionChip(
|
||||
icon = Icons.Filled.Notifications,
|
||||
label = null,
|
||||
contentDescription = translation["announcements_button_description"]
|
||||
) {
|
||||
showAnnouncementsDialog = true
|
||||
loadAnnouncements()
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.wrapContentWidth(Alignment.End),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
HomeActionChips()
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
HeroSection(
|
||||
versionName = BuildConfig.VERSION_NAME,
|
||||
latestUpdate = latestUpdate,
|
||||
downloadState = downloadState,
|
||||
downloadProgress = downloadProgress,
|
||||
onUpdateAction = onUpdateButtonClick,
|
||||
channelLabel = channelLabel,
|
||||
isPurrAuraActive = isPurrAuraActive,
|
||||
onWebsiteClick = {
|
||||
context.androidContext.openLink(
|
||||
"https://purrfectsnap.vercel.app/",
|
||||
context.translation["toast_open_link_failed"]
|
||||
)
|
||||
},
|
||||
onTelegramClick = {
|
||||
context.androidContext.openLink(
|
||||
"https://t.me/purrfectsnap_official",
|
||||
context.translation["toast_open_link_failed"]
|
||||
)
|
||||
},
|
||||
onGithubClick = {
|
||||
context.androidContext.openLink(
|
||||
"https://github.com/particle-box/PurrfectSnap",
|
||||
context.translation["toast_open_link_failed"]
|
||||
)
|
||||
},
|
||||
authorName = "ETERNAL",
|
||||
onManageClick = { routes.settings.navigate() },
|
||||
avenirNext = avenirNext,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
AnimatedContent(targetState = selectedTiles.isNotEmpty(), label = "QuickActionsAnim") { hasQuickActions ->
|
||||
val quickCardShape = RoundedCornerShape(34.dp)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = cardMargin, vertical = 10.dp),
|
||||
shape = quickCardShape,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 24.dp,
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.05f))
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Brush.linearGradient(quickActionsGradientColors))
|
||||
.padding(horizontal = 24.dp, vertical = 28.dp)
|
||||
.padding(bottom = navigationBarPadding + 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (!hasQuickActions) {
|
||||
Text(
|
||||
translation["quick_actions_title"],
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White.copy(alpha = 0.85f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Widgets,
|
||||
contentDescription = translation["quick_actions_icon_description"],
|
||||
modifier = Modifier.size(72.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = translation["quick_actions_empty_title"],
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = translation["quick_actions_empty_subtitle"],
|
||||
fontSize = 14.sp,
|
||||
color = Color.White.copy(alpha = 0.75f),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
Button(
|
||||
onClick = { showQuickActionsMenu = true },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White,
|
||||
contentColor = Color(0xFF1B152E)
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = translation["add_quick_action_description"],
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(text = translation["quick_actions_add_tile_button"])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
translation["quick_actions_title"],
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
color = Color.White,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Clip
|
||||
)
|
||||
Text(
|
||||
text = translation.format("quick_actions_count_label", "count" to selectedTiles.size.toString()),
|
||||
fontSize = 13.sp,
|
||||
color = Color.White.copy(alpha = 0.75f),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.wrapContentWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = { showQuickActionsMenu = true },
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)),
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.ic_manage),
|
||||
contentDescription = translation["manage_quick_actions_description"],
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(text = translation["quick_actions_manage_button"])
|
||||
}
|
||||
}
|
||||
}
|
||||
val spacing = 12.dp
|
||||
val gridPadding = 8.dp
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
|
||||
val preferredTileWidth = 100.dp
|
||||
val columns = ((maxWidth + spacing) / (preferredTileWidth + spacing))
|
||||
.toInt()
|
||||
.coerceAtLeast(2)
|
||||
.coerceAtMost(4)
|
||||
val computedWidth = (maxWidth - gridPadding * 2 - spacing * (columns - 1)) / columns
|
||||
val tileWidth = if (computedWidth < preferredTileWidth) computedWidth else preferredTileWidth
|
||||
FlowRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(all = gridPadding),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalArrangement = Arrangement.spacedBy(spacing),
|
||||
maxItemsInEachRow = columns
|
||||
) {
|
||||
selectedTiles.forEach { tileName ->
|
||||
val cardEntry = cards.entries.find { entry -> entry.key.first == tileName } ?: return@forEach
|
||||
val (card, action) = cardEntry
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.width(tileWidth)
|
||||
.aspectRatio(1.05f)
|
||||
.scaleOnPress(interactionSource)
|
||||
.clickable { action(routes) },
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = Color.White.copy(alpha = 0.06f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f))
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.3f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.22f)
|
||||
)
|
||||
)
|
||||
)
|
||||
.clipToBounds()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(all = 10.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = card.second, contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(44.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = card.first,
|
||||
lineHeight = 16.sp,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
color = Color.White,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
while (true) {
|
||||
delay(300)
|
||||
value = context.config.root.global.uiSettings.managerTheme.get()
|
||||
}
|
||||
}
|
||||
|
||||
if (showChangelogDialog && latestUpdate != null) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showChangelogDialog = false },
|
||||
title = translation["changelog_dialog_title"],
|
||||
text = "",
|
||||
icon = Icons.Filled.Info,
|
||||
confirmButtonText = translation["changelog_dialog_update_button"],
|
||||
onConfirm = {
|
||||
showChangelogDialog = false
|
||||
handleUpdateAction()
|
||||
},
|
||||
dismissButtonText = translation["changelog_dialog_cancel_button"],
|
||||
onDismiss = { showChangelogDialog = false },
|
||||
confirmEnabled = !changelogLoading,
|
||||
showCloseButton = false,
|
||||
customContent = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 120.dp, max = 340.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
when {
|
||||
changelogLoading -> {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(28.dp),
|
||||
strokeWidth = 3.dp,
|
||||
color = Color.White
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Text(
|
||||
text = translation["changelog_dialog_loading"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
changelogError != null -> {
|
||||
Text(
|
||||
text = changelogError ?: translation["changelog_dialog_error"],
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Text(
|
||||
text = changelogText ?: translation["changelog_dialog_empty"],
|
||||
color = PurrfectPalette.textPrimary,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showAnnouncementsDialog) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showAnnouncementsDialog = false },
|
||||
title = translation["announcements_dialog_title"],
|
||||
text = "",
|
||||
icon = Icons.Filled.Info,
|
||||
confirmButtonText = translation["announcements_dialog_close_button"],
|
||||
onConfirm = { showAnnouncementsDialog = false },
|
||||
confirmEnabled = !announcementsLoading,
|
||||
showCloseButton = false,
|
||||
customContent = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 120.dp, max = 340.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
when {
|
||||
announcementsLoading -> {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(28.dp),
|
||||
strokeWidth = 3.dp,
|
||||
color = Color.White
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Text(
|
||||
text = translation["announcements_dialog_loading"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
announcementsError != null -> {
|
||||
Text(
|
||||
text = announcementsError ?: translation["announcements_dialog_error"],
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Text(
|
||||
text = announcementsText ?: translation["announcements_dialog_empty"],
|
||||
color = PurrfectPalette.textPrimary,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showQuickActionsMenu) {
|
||||
QuickActionsDialog(
|
||||
quickActions = cards,
|
||||
selectedQuickActions = selectedTiles,
|
||||
onDismiss = { showQuickActionsMenu = false },
|
||||
onSave = { newList ->
|
||||
val previous = selectedTiles.toList()
|
||||
val removed = previous.filter { it !in newList }
|
||||
removed.forEach { clearTileSpan(it); clearTileOffset(it) }
|
||||
newList.forEach { clearTileOffset(it) }
|
||||
selectedTiles.clear()
|
||||
selectedTiles.addAll(newList)
|
||||
prefs.edit().putBoolean(QUICK_TILES_INITIALIZED_PREF, true).apply()
|
||||
context.coroutineScope.launch {
|
||||
context.database.setQuickTiles(selectedTiles)
|
||||
}
|
||||
showQuickActionsMenu = false
|
||||
},
|
||||
translation = translation
|
||||
)
|
||||
key(themeId) {
|
||||
with(ManagerTheme.fromId(themeId).theme) {
|
||||
this@HomeRootSection.HomeScreen(nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractChangelogForVersion(raw: String, version: String): String {
|
||||
internal fun extractChangelogForVersion(raw: String, version: String): String {
|
||||
val lines = raw.lines()
|
||||
val headerRegex = Regex("^\\s*#+\\s*v?${Regex.escape(version)}\\b", RegexOption.IGNORE_CASE)
|
||||
val collected = mutableListOf<String>()
|
||||
|
||||
@@ -34,8 +34,6 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.net.toUri
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
@@ -45,7 +43,9 @@ import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.R
|
||||
import me.eternal.purrfectsnap.common.action.EnumAction
|
||||
import me.eternal.purrfectsnap.common.bridge.InternalFileHandleType
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
@@ -53,6 +53,7 @@ import me.eternal.purrfectsnap.storage.getAllScopeNotes
|
||||
import me.eternal.purrfectsnap.storage.setAllScopeNotes
|
||||
import me.eternal.purrfectsnap.task.UpdateCheckWorker
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.setup.Requirements
|
||||
@@ -68,10 +69,10 @@ import java.util.concurrent.TimeUnit
|
||||
|
||||
class HomeSettings : Routes.Route() {
|
||||
override val translation by lazy { context.translation.getCategory("manager.sections.home_settings") }
|
||||
private lateinit var activityLauncherHelper: ActivityLauncherHelper
|
||||
internal lateinit var activityLauncherHelper: ActivityLauncherHelper
|
||||
private val dialogs by lazy { AlertDialogs(context.translation) }
|
||||
|
||||
private fun scheduleUpdateCheck() {
|
||||
internal fun scheduleUpdateCheck() {
|
||||
val workManager = WorkManager.getInstance(context.androidContext)
|
||||
val updateSettings = context.config.root.global.updateSettings
|
||||
var configDirty = false
|
||||
@@ -128,11 +129,31 @@ class HomeSettings : Routes.Route() {
|
||||
workManager.cancelUniqueWork("purrfectsnap_update_check")
|
||||
}
|
||||
}
|
||||
|
||||
override val init: () -> Unit = {
|
||||
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
|
||||
}
|
||||
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = { nav ->
|
||||
val themeId by produceState(
|
||||
initialValue = context.config.root.global.uiSettings.managerTheme.get()
|
||||
) {
|
||||
while (true) {
|
||||
delay(300)
|
||||
value = context.config.root.global.uiSettings.managerTheme.get()
|
||||
}
|
||||
}
|
||||
|
||||
key(themeId) {
|
||||
val currentTheme = ManagerTheme.fromId(themeId).theme
|
||||
with(currentTheme) {
|
||||
this@HomeSettings.SettingsScreen(nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RowTitle(title: String) {
|
||||
internal fun RowTitle(title: String) {
|
||||
Text(
|
||||
text = title,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp),
|
||||
@@ -141,8 +162,9 @@ class HomeSettings : Routes.Route() {
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PremiumPreferenceToggle(
|
||||
internal fun PremiumPreferenceToggle(
|
||||
sharedPreferences: SharedPreferences,
|
||||
key: String,
|
||||
text: String,
|
||||
@@ -214,7 +236,7 @@ class HomeSettings : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PreferenceToggle(sharedPreferences: SharedPreferences, key: String, text: String) {
|
||||
internal fun PreferenceToggle(sharedPreferences: SharedPreferences, key: String, text: String) {
|
||||
val realKey = "debug_$key"
|
||||
var value by remember { mutableStateOf(sharedPreferences.getBoolean(realKey, false)) }
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
@@ -246,7 +268,7 @@ class HomeSettings : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RowAction(key: String, requireConfirmation: Boolean = false, action: () -> Unit) {
|
||||
internal fun RowAction(key: String, requireConfirmation: Boolean = false, action: () -> Unit) {
|
||||
var confirmationDialog by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
@@ -294,8 +316,9 @@ class HomeSettings : Routes.Route() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShiftedRow(
|
||||
internal fun ShiftedRow(
|
||||
modifier: Modifier = Modifier,
|
||||
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
|
||||
verticalAlignment: Alignment.Vertical = Alignment.Top,
|
||||
@@ -308,660 +331,52 @@ class HomeSettings : Routes.Route() {
|
||||
) { content(this) }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
||||
val contextC = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val scrollState = rememberScrollState()
|
||||
val positiveLabel = context.translation["button.positive"]
|
||||
val negativeLabel = context.translation["button.negative"]
|
||||
val importLabel = context.translation["button.import"]
|
||||
val sharedButtonColors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White.copy(alpha = 0.12f),
|
||||
@Composable
|
||||
internal fun GlassCard(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)),
|
||||
contentColor = Color.White
|
||||
)
|
||||
val sharedOutlinedColors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White)
|
||||
var showResetSetupDialog by remember { mutableStateOf(false) }
|
||||
|
||||
@Composable
|
||||
fun GlassCard(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)),
|
||||
contentColor = Color.White
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AestheticDropdownField(
|
||||
value: String,
|
||||
expanded: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val shape = RoundedCornerShape(16.dp)
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clip(shape)
|
||||
.background(Color.White.copy(alpha = 0.06f))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.16f), shape)
|
||||
.clickable { onClick() }
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(text = value, color = Color.White)
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
|
||||
}
|
||||
}
|
||||
|
||||
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
if (showResetSetupDialog) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showResetSetupDialog = false },
|
||||
title = translation["reset_setup_dialog_title"],
|
||||
text = translation["reset_setup_dialog_text"],
|
||||
icon = Icons.Filled.Warning,
|
||||
confirmButtonText = positiveLabel,
|
||||
dismissButtonText = negativeLabel,
|
||||
onConfirm = {
|
||||
showResetSetupDialog = false
|
||||
context.sharedPreferences.edit()
|
||||
.remove("setup_in_progress")
|
||||
.remove("setup_current_route")
|
||||
.remove("setup_skip_patch")
|
||||
.remove("setup_install_mode")
|
||||
.apply()
|
||||
|
||||
context.config.reset()
|
||||
context.config.writeConfig()
|
||||
|
||||
val intent = android.content.Intent(
|
||||
context.androidContext,
|
||||
me.eternal.purrfectsnap.ui.setup.SetupActivity::class.java
|
||||
)
|
||||
intent.flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||
android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
context.androidContext.startActivity(intent)
|
||||
routes.navController.popBackStack()
|
||||
},
|
||||
onDismiss = { showResetSetupDialog = false },
|
||||
showCloseButton = false
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
shape = RoundedCornerShape(26.dp),
|
||||
color = Color.White.copy(alpha = 0.07f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
),
|
||||
contentColor = Color.White
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
IconButton(onClick = { routes.navController.popBackStack() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = translation["manager.routes.home_settings"],
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
IconButton(onClick = { routes.navigation?.openBottomBarCustomization = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Tune,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.85f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
GlassCard {
|
||||
RowTitle(title = translation["actions_title"])
|
||||
EnumAction.entries.forEach { enumAction ->
|
||||
RowAction(key = enumAction.key) { context.launchActionIntent(enumAction) }
|
||||
}
|
||||
RowAction(key = "regen_mappings") { context.checkForRequirements(Requirements.MAPPINGS) }
|
||||
RowAction(key = "change_language") { context.checkForRequirements(Requirements.LANGUAGE) }
|
||||
}
|
||||
|
||||
GlassCard {
|
||||
RowTitle(title = translation["ui_settings_title"])
|
||||
ShiftedRow {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 55.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(text = translation["haptic_feedback_label"])
|
||||
var hapticFeedbackEnabled by remember { mutableStateOf(context.config.root.global.uiSettings.hapticFeedback.getNullable() ?: true) }
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
Switch(
|
||||
checked = hapticFeedbackEnabled,
|
||||
onCheckedChange = {
|
||||
if (it) {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
hapticFeedbackEnabled = it
|
||||
context.config.root.global.uiSettings.hapticFeedback.set(it)
|
||||
context.config.writeConfig()
|
||||
},
|
||||
modifier = Modifier.padding(end = 26.dp),
|
||||
colors = purrfectSwitchColors()
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 55.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(text = translation["use_system_toasts_label"])
|
||||
var useSystemToasts by remember { mutableStateOf(context.config.root.global.uiSettings.useSystemToasts.getNullable() ?: false) }
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
Switch(
|
||||
checked = useSystemToasts,
|
||||
onCheckedChange = {
|
||||
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
useSystemToasts = it
|
||||
context.config.root.global.uiSettings.useSystemToasts.set(it)
|
||||
context.config.writeConfig()
|
||||
},
|
||||
modifier = Modifier.padding(end = 26.dp),
|
||||
colors = purrfectSwitchColors()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GlassCard {
|
||||
RowTitle(title = translation["updates_title"])
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
var autoUpdateCheck by remember { mutableStateOf(context.config.root.global.updateSettings.autoUpdateCheck.getNullable() ?: true) }
|
||||
var selectedChannel by remember { mutableStateOf(context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable") }
|
||||
var channelMenuExpanded by remember { mutableStateOf(false) }
|
||||
ShiftedRow {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 55.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(text = translation["auto_update_check"])
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
Switch(
|
||||
checked = autoUpdateCheck,
|
||||
onCheckedChange = {
|
||||
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
autoUpdateCheck = it
|
||||
context.config.root.global.updateSettings.autoUpdateCheck.set(it)
|
||||
context.config.writeConfig()
|
||||
scheduleUpdateCheck()
|
||||
},
|
||||
modifier = Modifier.padding(end = 26.dp),
|
||||
colors = purrfectSwitchColors()
|
||||
)
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(visible = autoUpdateCheck) {
|
||||
val spacingModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 26.dp)
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = channelMenuExpanded,
|
||||
onExpandedChange = { channelMenuExpanded = it },
|
||||
modifier = spacingModifier
|
||||
) {
|
||||
AestheticDropdownField(
|
||||
value = translation.getOrNull("update_channel_${selectedChannel}") ?: selectedChannel,
|
||||
expanded = channelMenuExpanded,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(MenuAnchorType.PrimaryNotEditable),
|
||||
onClick = { channelMenuExpanded = true }
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = channelMenuExpanded,
|
||||
onDismissRequest = { channelMenuExpanded = false }
|
||||
) {
|
||||
listOf("stable", "prerelease").forEach { channel ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(text = translation.getOrNull("update_channel_${channel}") ?: channel) },
|
||||
onClick = {
|
||||
selectedChannel = channel
|
||||
channelMenuExpanded = false
|
||||
context.config.root.global.updateSettings.updateChannel.set(channel)
|
||||
context.config.writeConfig()
|
||||
scheduleUpdateCheck()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GlassCard {
|
||||
RowTitle(title = translation["reset_setup_title"])
|
||||
ShiftedRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 55.dp)
|
||||
.clickable {
|
||||
showResetSetupDialog = true
|
||||
},
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = translation["reset_setup_action"],
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
lineHeight = 20.sp
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
|
||||
contentDescription = translation["reset_setup_action"],
|
||||
modifier = Modifier.padding(end = 14.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
GlassCard {
|
||||
RowTitle(title = translation["message_logger_title"])
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
var storedMessagesCount by rememberAsyncMutableState(defaultValue = 0) {
|
||||
context.messageLogger.getStoredMessageCount()
|
||||
}
|
||||
var storedStoriesCount by rememberAsyncMutableState(defaultValue = 0) {
|
||||
context.messageLogger.getStoredStoriesCount()
|
||||
}
|
||||
var showImportDialog by remember { mutableStateOf(false) }
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(5.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
val summary = translation.format(
|
||||
"message_logger_summary",
|
||||
"messageCount" to storedMessagesCount.toString(),
|
||||
"storyCount" to storedStoriesCount.toString()
|
||||
).replace("\n", " | ")
|
||||
Text(
|
||||
summary,
|
||||
maxLines = 2,
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
FlowRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.CenterHorizontally),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
runCatching {
|
||||
activityLauncherHelper.saveFile("message_logger.db", "application/octet-stream") { uri ->
|
||||
context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { outputStream ->
|
||||
context.messageLogger.databaseFile.inputStream().use { inputStream ->
|
||||
inputStream.copyTo(outputStream)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to export database", it)
|
||||
context.longToast(translation.format("export_database_failed_toast", "message" to (it.localizedMessage ?: "")))
|
||||
}
|
||||
},
|
||||
colors = sharedButtonColors,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Text(text = translation["export_button"])
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
runCatching {
|
||||
activityLauncherHelper.openFile("application/octet-stream") { uri ->
|
||||
val tempFile = File(context.androidContext.cacheDir, "view_message_logger.db")
|
||||
context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { inputStream ->
|
||||
FileOutputStream(tempFile).use { outputStream ->
|
||||
inputStream.copyTo(outputStream)
|
||||
}
|
||||
}
|
||||
routes.viewLoggerHistory.navigate {
|
||||
put("uri", URLEncoder.encode(tempFile.toUri().toString(), "UTF-8"))
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to open file", it)
|
||||
context.longToast(
|
||||
translation.format(
|
||||
"open_file_failed_toast",
|
||||
"message" to (it.localizedMessage ?: "")
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = sharedButtonColors,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Text(text = translation["view_button"])
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
runCatching {
|
||||
context.messageLogger.purgeAll()
|
||||
storedMessagesCount = 0
|
||||
storedStoriesCount = 0
|
||||
}.onFailure {
|
||||
context.log.error("Failed to clear messages", it)
|
||||
context.longToast(translation.format("clear_messages_failed_toast", "message" to (it.localizedMessage ?: "")))
|
||||
}.onSuccess {
|
||||
context.shortToast(translation["success_toast"])
|
||||
}
|
||||
},
|
||||
colors = sharedButtonColors,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Text(text = translation["clear_button"])
|
||||
}
|
||||
Button(
|
||||
onClick = { showImportDialog = true },
|
||||
colors = sharedButtonColors,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Text(text = translation["import_button"])
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedButton(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(5.dp),
|
||||
onClick = { routes.loggerHistory.navigate() },
|
||||
colors = sharedOutlinedColors,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.2f))
|
||||
) {
|
||||
Text(translation["view_logger_history_button"])
|
||||
}
|
||||
if (showImportDialog) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showImportDialog = false },
|
||||
title = translation["message_logger_import_title"],
|
||||
text = translation["message_logger_import_text"],
|
||||
icon = Icons.Filled.Info,
|
||||
confirmButtonText = importLabel,
|
||||
dismissButtonText = context.translation["button.cancel"],
|
||||
onConfirm = {
|
||||
showImportDialog = false
|
||||
runCatching {
|
||||
activityLauncherHelper.openFile("application/octet-stream") { uri ->
|
||||
runCatching {
|
||||
context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { inputStream ->
|
||||
context.messageLogger.databaseFile.outputStream().use { outputStream ->
|
||||
inputStream.copyTo(outputStream)
|
||||
}
|
||||
} ?: throw IllegalStateException("Unable to open selected file")
|
||||
storedMessagesCount = context.messageLogger.getStoredMessageCount()
|
||||
storedStoriesCount = context.messageLogger.getStoredStoriesCount()
|
||||
context.shortToast(translation["success_toast"])
|
||||
context.log.info("Imported message logger from $uri", "MessageLogger")
|
||||
}.onFailure {
|
||||
context.log.error("Failed to import message logger", it)
|
||||
context.longToast(
|
||||
translation.format(
|
||||
"import_failed_toast",
|
||||
"message" to (it.localizedMessage ?: it.message ?: "")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to launch import picker", it)
|
||||
context.longToast(
|
||||
translation.format(
|
||||
"import_failed_toast",
|
||||
"message" to (it.localizedMessage ?: it.message ?: "")
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
onDismiss = { showImportDialog = false },
|
||||
showCloseButton = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GlassCard {
|
||||
RowTitle(title = translation["friend_notes_title"])
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
text = translation["friend_notes_description"],
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 5.dp),
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 5.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Button(onClick = {
|
||||
runCatching {
|
||||
val notes = context.database.getAllScopeNotes()
|
||||
if (notes.isEmpty()) {
|
||||
context.shortToast(translation["friend_notes_no_notes_to_backup"])
|
||||
return@runCatching
|
||||
}
|
||||
val json = context.gson.toJson(notes)
|
||||
activityLauncherHelper.saveFile("friend_notes_backup.json", "application/json") { uri ->
|
||||
context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use {
|
||||
it.write(json.toByteArray())
|
||||
}
|
||||
context.shortToast(translation["friend_notes_backup_success"])
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to backup notes", it)
|
||||
context.longToast(translation.format("friend_notes_backup_failure", "error" to (it.localizedMessage ?: "")))
|
||||
}
|
||||
},
|
||||
colors = sharedButtonColors,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Text(text = translation["backup_button"])
|
||||
}
|
||||
Button(onClick = {
|
||||
runCatching {
|
||||
activityLauncherHelper.openFile("application/json") { uri ->
|
||||
context.androidContext.contentResolver.openInputStream(uri.toUri())?.use {
|
||||
val json = it.reader().readText()
|
||||
val notes = context.gson.fromJson<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))
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun AestheticDropdownField(
|
||||
value: String,
|
||||
expanded: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val shape = RoundedCornerShape(16.dp)
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clip(shape)
|
||||
.background(Color.White.copy(alpha = 0.06f))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.16f), shape)
|
||||
.clickable { onClick() }
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(text = value, color = Color.White)
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +155,7 @@ class RetroGameScreen : Routes.Route() {
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
context.shortToast(routes.homeAbout.translation["about_magic_toast"])
|
||||
resetGame()
|
||||
while (true) {
|
||||
delay(16)
|
||||
|
||||
@@ -30,7 +30,9 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.*
|
||||
import me.eternal.purrfectsnap.common.scripting.type.ModuleInfo
|
||||
import me.eternal.purrfectsnap.common.scripting.ui.EnumScriptInterface
|
||||
@@ -44,11 +46,11 @@ import me.eternal.purrfectsnap.common.util.ktx.openLink
|
||||
import me.eternal.purrfectsnap.storage.isScriptEnabled
|
||||
import me.eternal.purrfectsnap.storage.setScriptEnabled
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticEmptyState
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
|
||||
import me.eternal.purrfectsnap.ui.util.Dialog
|
||||
import me.eternal.purrfectsnap.ui.util.chooseFolder
|
||||
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
|
||||
import me.eternal.purrfectsnap.ui.util.pullrefresh.PullRefreshIndicator
|
||||
@@ -57,15 +59,15 @@ import me.eternal.purrfectsnap.ui.util.pullrefresh.rememberPullRefreshState
|
||||
|
||||
class ScriptingRootSection : Routes.Route() {
|
||||
override val translation by lazy { context.translation.getCategory("manager.scripting") }
|
||||
private lateinit var activityLauncherHelper: ActivityLauncherHelper
|
||||
val reloadDispatcher = AsyncUpdateDispatcher(updateOnFirstComposition = false)
|
||||
private var selectedTab by mutableStateOf(0)
|
||||
internal lateinit var activityLauncherHelper: ActivityLauncherHelper
|
||||
internal val reloadDispatcher = AsyncUpdateDispatcher(updateOnFirstComposition = false)
|
||||
internal var selectedTab by mutableStateOf(0)
|
||||
|
||||
override val init: () -> Unit = {
|
||||
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
|
||||
}
|
||||
|
||||
suspend fun isScriptInstalledByUrl(scriptUrl: String): Boolean {
|
||||
internal suspend fun isScriptInstalledByUrl(scriptUrl: String): Boolean {
|
||||
return try {
|
||||
val installedScripts = context.scriptManager.getSyncedModules()
|
||||
installedScripts.any { module ->
|
||||
@@ -76,7 +78,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadScript(scriptUrl: String, onComplete: () -> Unit) {
|
||||
internal fun downloadScript(scriptUrl: String, onComplete: () -> Unit) {
|
||||
context.coroutineScope.launch {
|
||||
if (isScriptInstalledByUrl(scriptUrl)) {
|
||||
context.shortToast(translation["script_already_installed"])
|
||||
@@ -97,7 +99,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImportRemoteScript(
|
||||
internal fun ImportRemoteScript(
|
||||
dismiss: () -> Unit
|
||||
) {
|
||||
var url by remember { mutableStateOf("") }
|
||||
@@ -172,7 +174,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModuleActions(
|
||||
internal fun ModuleActions(
|
||||
script: ModuleInfo,
|
||||
canUpdate: Boolean,
|
||||
dismiss: () -> Unit
|
||||
@@ -270,7 +272,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ModuleItem(script: ModuleInfo) {
|
||||
internal fun ModuleItem(script: ModuleInfo) {
|
||||
var enabled by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(script)) {
|
||||
context.database.isScriptEnabled(script.name)
|
||||
}
|
||||
@@ -430,7 +432,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
override val floatingActionButton: @Composable () -> Unit = {}
|
||||
|
||||
@Composable
|
||||
private fun SelectFolderButton(onClick: () -> Unit) {
|
||||
internal fun SelectFolderButton(onClick: () -> Unit) {
|
||||
val label = translation["select_folder_button"]
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -483,7 +485,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ScriptSettings(script: ModuleInfo) {
|
||||
internal fun ScriptSettings(script: ModuleInfo) {
|
||||
val settingsInterface = remember {
|
||||
val module =
|
||||
context.scriptManager.runtime.getModuleByName(script.name) ?: return@remember null
|
||||
@@ -500,84 +502,23 @@ class ScriptingRootSection : Routes.Route() {
|
||||
}
|
||||
}
|
||||
|
||||
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
|
||||
val scriptingFolder by rememberAsyncMutableState(
|
||||
defaultValue = null,
|
||||
updateDispatcher = reloadDispatcher
|
||||
) { context.scriptManager.getScriptsFolder() }
|
||||
val tabTitles = listOf(translation["installed_scripts_tab"], translation["catalog_tab"])
|
||||
var showImportDialog by remember { mutableStateOf(false) }
|
||||
var showToast by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(scriptingFolder) {
|
||||
if (scriptingFolder == null && selectedTab != 0) {
|
||||
selectedTab = 0
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = { nav ->
|
||||
val themeId by produceState(initialValue = context.config.root.global.uiSettings.managerTheme.get()) {
|
||||
while (true) {
|
||||
delay(300)
|
||||
value = context.config.root.global.uiSettings.managerTheme.get()
|
||||
}
|
||||
}
|
||||
|
||||
if (showImportDialog) {
|
||||
ImportRemoteScript { showImportDialog = false }
|
||||
}
|
||||
if (showToast) {
|
||||
LaunchedEffect(showToast) {
|
||||
context.shortToast(translation["select_scripts_folder_toast"])
|
||||
showToast = false
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
ScriptingHeader(
|
||||
titles = tabTitles,
|
||||
selectedTab = selectedTab,
|
||||
onTabSelected = { index ->
|
||||
if (index == 1 && scriptingFolder == null) {
|
||||
showToast = true
|
||||
} else {
|
||||
selectedTab = index
|
||||
}
|
||||
},
|
||||
onImport = {
|
||||
if (scriptingFolder == null) showToast = true else showImportDialog = true
|
||||
},
|
||||
onOpenFolder = {
|
||||
if (scriptingFolder == null) {
|
||||
showToast = true
|
||||
} else {
|
||||
scriptingFolder?.let {
|
||||
context.androidContext.openLink(
|
||||
it.uri.toString(),
|
||||
context.translation["toast_open_link_failed"]
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onManageRepos = { routes.manageScriptRepos.navigate() },
|
||||
onDocs = {
|
||||
context.androidContext.openLink(
|
||||
"https://github.com/SnapEnhance/scripting-docs",
|
||||
context.translation["toast_open_link_failed"]
|
||||
)
|
||||
},
|
||||
folderSelected = scriptingFolder != null
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
when (selectedTab) {
|
||||
0 -> InstalledTabContent(
|
||||
scriptingFolder = scriptingFolder
|
||||
)
|
||||
1 -> CatalogTabContent(
|
||||
scriptingFolder = scriptingFolder
|
||||
)
|
||||
key(themeId) {
|
||||
with(ManagerTheme.fromId(themeId).theme) {
|
||||
this@ScriptingRootSection.ScriptingScreen(nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InstalledTabContent(
|
||||
internal fun InstalledTabContent(
|
||||
scriptingFolder: DocumentFile?
|
||||
) {
|
||||
val scriptModules by rememberAsyncMutableState(
|
||||
@@ -800,7 +741,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CatalogTabContent(
|
||||
internal fun CatalogTabContent(
|
||||
scriptingFolder: DocumentFile?
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
@@ -833,7 +774,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScriptingHeader(
|
||||
internal fun ScriptingHeader(
|
||||
titles: List<String>,
|
||||
selectedTab: Int,
|
||||
onTabSelected: (Int) -> Unit,
|
||||
@@ -911,7 +852,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScriptingTabSwitcher(
|
||||
internal fun ScriptingTabSwitcher(
|
||||
titles: List<String>,
|
||||
selectedTab: Int,
|
||||
onTabSelected: (Int) -> Unit
|
||||
|
||||
@@ -35,6 +35,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.R
|
||||
import me.eternal.purrfectsnap.common.ReceiversConfig
|
||||
@@ -46,21 +47,22 @@ import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie
|
||||
import me.eternal.purrfectsnap.common.util.snap.SnapWidgetBroadcastReceiverHelper
|
||||
import me.eternal.purrfectsnap.storage.*
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.coil.BitmojiImage
|
||||
|
||||
class SocialRootSection : Routes.Route() {
|
||||
private var friendList: List<MessagingFriendInfo> by mutableStateOf(emptyList())
|
||||
private var groupList: List<MessagingGroupInfo> by mutableStateOf(emptyList())
|
||||
internal var friendList: List<MessagingFriendInfo> by mutableStateOf(emptyList())
|
||||
internal var groupList: List<MessagingGroupInfo> by mutableStateOf(emptyList())
|
||||
|
||||
private fun updateScopeLists() {
|
||||
internal fun updateScopeLists() {
|
||||
context.coroutineScope.launch {
|
||||
friendList = context.database.getFriends(descOrder = true)
|
||||
groupList = context.database.getGroups()
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestLatestSnapshot() {
|
||||
internal fun requestLatestSnapshot() {
|
||||
runCatching {
|
||||
context.androidContext.sendBroadcast(
|
||||
SnapWidgetBroadcastReceiverHelper.create(ReceiversConfig.BRIDGE_SYNC_ACTION) {}
|
||||
@@ -71,7 +73,7 @@ class SocialRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScopeList(
|
||||
internal fun ScopeList(
|
||||
scope: SocialScope,
|
||||
friends: List<MessagingFriendInfo>,
|
||||
groups: List<MessagingGroupInfo>
|
||||
@@ -88,7 +90,6 @@ class SocialRootSection : Routes.Route() {
|
||||
contentPadding = PaddingValues(start = 10.dp, end = 10.dp, bottom = routes.bottomPadding + 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
//check if scope list is empty
|
||||
val listSize = list.size
|
||||
|
||||
if (listSize == 0) {
|
||||
@@ -197,147 +198,23 @@ class SocialRootSection : Routes.Route() {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
||||
val titles = remember {
|
||||
listOf(translation["friends_tab"], translation["groups_tab"])
|
||||
}
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val pagerState = rememberPagerState { titles.size }
|
||||
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||
var searchActive by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
context.database.receiveMessagingDataCallback = { friends, groups ->
|
||||
friendList = friends
|
||||
groupList = groups
|
||||
}
|
||||
updateScopeLists()
|
||||
requestLatestSnapshot()
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
context.database.receiveMessagingDataCallback = { _, _ -> }
|
||||
}
|
||||
}
|
||||
val normalizedQuery = remember(searchQuery) { searchQuery.trim() }
|
||||
val filteredFriends = remember(friendList, normalizedQuery) {
|
||||
if (normalizedQuery.isBlank()) {
|
||||
friendList
|
||||
} else {
|
||||
friendList.filter {
|
||||
it.mutableUsername.contains(normalizedQuery, ignoreCase = true) ||
|
||||
it.displayName?.contains(normalizedQuery, ignoreCase = true) == true
|
||||
}
|
||||
}
|
||||
}
|
||||
val filteredGroups = remember(groupList, normalizedQuery) {
|
||||
if (normalizedQuery.isBlank()) {
|
||||
groupList
|
||||
} else {
|
||||
groupList.filter { it.name.contains(normalizedQuery, ignoreCase = true) }
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = { nav ->
|
||||
val themeId by produceState(initialValue = context.config.root.global.uiSettings.managerTheme.get()) {
|
||||
while (true) {
|
||||
delay(300)
|
||||
value = context.config.root.global.uiSettings.managerTheme.get()
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
SocialHeader(
|
||||
titles = titles,
|
||||
pagerState = pagerState,
|
||||
onTabSelected = { index ->
|
||||
coroutineScope.launch { pagerState.animateScrollToPage(index) }
|
||||
},
|
||||
friendCount = friendList.size,
|
||||
groupCount = groupList.size,
|
||||
searchActive = searchActive,
|
||||
onSearchToggle = {
|
||||
searchActive = !searchActive
|
||||
if (!searchActive) searchQuery = ""
|
||||
}
|
||||
)
|
||||
if (searchActive) {
|
||||
val searchHint = context.translation["manager.dialogs.add_friend.search_hint"]
|
||||
val searchShape = RoundedCornerShape(18.dp)
|
||||
val searchBorder = Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 6.dp),
|
||||
shape = searchShape,
|
||||
color = Color.White.copy(alpha = 0.05f),
|
||||
border = BorderStroke(1.dp, searchBorder),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(PurrfectPalette.cardOverlay, searchShape)
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Search,
|
||||
contentDescription = searchHint,
|
||||
tint = PurrfectPalette.textSecondary
|
||||
)
|
||||
BasicTextField(
|
||||
value = searchQuery,
|
||||
onValueChange = { searchQuery = it },
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = Color.White,
|
||||
fontSize = 15.sp
|
||||
),
|
||||
cursorBrush = SolidColor(PurrfectPalette.glowSecondary),
|
||||
modifier = Modifier.weight(1f)
|
||||
) { innerTextField ->
|
||||
if (searchQuery.isEmpty()) {
|
||||
Text(
|
||||
text = searchHint,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
if (searchQuery.isNotEmpty()) {
|
||||
IconButton(onClick = { searchQuery = "" }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = translation["clear_search_button_description"],
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
HorizontalPager(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
state = pagerState
|
||||
) { page ->
|
||||
when (page) {
|
||||
0 -> ScopeList(SocialScope.FRIEND, filteredFriends, filteredGroups)
|
||||
1 -> ScopeList(SocialScope.GROUP, filteredFriends, filteredGroups)
|
||||
}
|
||||
key(themeId) {
|
||||
with(ManagerTheme.fromId(themeId).theme) {
|
||||
this@SocialRootSection.SocialScreen(nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SocialCard(
|
||||
internal fun SocialCard(
|
||||
scope: SocialScope,
|
||||
friend: MessagingFriendInfo?,
|
||||
group: MessagingGroupInfo?,
|
||||
@@ -500,7 +377,7 @@ class SocialRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SocialHeader(
|
||||
internal fun SocialHeader(
|
||||
titles: List<String>,
|
||||
pagerState: androidx.compose.foundation.pager.PagerState,
|
||||
onTabSelected: (Int) -> Unit,
|
||||
@@ -574,7 +451,7 @@ class SocialRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SocialTabSwitcher(
|
||||
internal fun SocialTabSwitcher(
|
||||
titles: List<String>,
|
||||
pagerState: androidx.compose.foundation.pager.PagerState,
|
||||
onTabSelected: (Int) -> Unit
|
||||
@@ -620,7 +497,7 @@ class SocialRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyState(scope: SocialScope) {
|
||||
internal fun EmptyState(scope: SocialScope) {
|
||||
val title = when (scope) {
|
||||
SocialScope.FRIEND -> translation.getOrNull("friends_empty_title") ?: translation["empty_hint"]
|
||||
SocialScope.GROUP -> translation.getOrNull("groups_empty_title") ?: translation["empty_hint"]
|
||||
@@ -647,13 +524,13 @@ class SocialRootSection : Routes.Route() {
|
||||
modifier = Modifier.size(26.dp)
|
||||
)
|
||||
Text(
|
||||
text = title,
|
||||
text = title ?: "",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 15.sp
|
||||
)
|
||||
Text(
|
||||
text = translation["social_empty_hint"],
|
||||
text = translation["social_empty_hint"] ?: "",
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
@@ -662,7 +539,7 @@ class SocialRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatPill(label: String, value: Int) {
|
||||
internal fun StatPill(label: String, value: Int) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(50),
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -25,13 +25,7 @@ import androidx.compose.material.icons.filled.FileOpen
|
||||
import androidx.compose.material.icons.filled.Store
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -44,6 +38,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList
|
||||
@@ -51,6 +46,7 @@ import me.eternal.purrfectsnap.common.ui.rememberAsyncUpdateDispatcher
|
||||
import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie
|
||||
import me.eternal.purrfectsnap.storage.*
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
|
||||
@@ -67,18 +63,18 @@ class FriendTrackerManagerRoot : Routes.Route() {
|
||||
}
|
||||
|
||||
override val translation by lazy { context.translation.getCategory("manager.friend_tracker") }
|
||||
private val titles by lazy {
|
||||
internal val titles by lazy {
|
||||
listOf(
|
||||
translation["rules_tab"],
|
||||
translation["logs_tab"]
|
||||
)
|
||||
}
|
||||
private var currentPage by mutableIntStateOf(0)
|
||||
private lateinit var logDeleteAction : () -> Unit
|
||||
private lateinit var exportAction : () -> Unit
|
||||
internal var currentPage by mutableIntStateOf(0)
|
||||
internal lateinit var logDeleteAction : () -> Unit
|
||||
internal lateinit var exportAction : () -> Unit
|
||||
|
||||
@Composable
|
||||
private fun TrackerIconButton(
|
||||
internal fun TrackerIconButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -114,7 +110,7 @@ class FriendTrackerManagerRoot : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrackerActionButton(
|
||||
internal fun TrackerActionButton(
|
||||
label: String,
|
||||
icon: ImageVector,
|
||||
onClick: () -> Unit,
|
||||
@@ -156,7 +152,7 @@ class FriendTrackerManagerRoot : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrackerPillButton(
|
||||
internal fun TrackerPillButton(
|
||||
label: String,
|
||||
icon: ImageVector,
|
||||
onClick: () -> Unit,
|
||||
@@ -304,7 +300,7 @@ class FriendTrackerManagerRoot : Routes.Route() {
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var activityLauncherHelper: ActivityLauncherHelper
|
||||
internal lateinit var activityLauncherHelper: ActivityLauncherHelper
|
||||
|
||||
override val init: () -> Unit = {
|
||||
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
|
||||
@@ -347,7 +343,7 @@ class FriendTrackerManagerRoot : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConfigRulesTab() {
|
||||
internal fun ConfigRulesTab() {
|
||||
val updateRules = rememberAsyncUpdateDispatcher()
|
||||
val rules = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = updateRules) {
|
||||
context.database.getTrackerRulesDesc()
|
||||
@@ -568,9 +564,9 @@ class FriendTrackerManagerRoot : Routes.Route() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
||||
@Composable
|
||||
internal fun TrackerScreenContent(nav: NavBackStackEntry) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val pagerState = rememberPagerState(initialPage = 0) { titles.size }
|
||||
currentPage = pagerState.currentPage
|
||||
@@ -625,47 +621,47 @@ class FriendTrackerManagerRoot : Routes.Route() {
|
||||
)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text = context.translation["manager.routes.friend_tracker"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 20.sp
|
||||
)
|
||||
Text(
|
||||
text = titles.getOrNull(pagerState.currentPage) ?: "",
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
if (pagerState.currentPage == 0) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.End
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text = context.translation["manager.routes.friend_tracker"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 20.sp
|
||||
)
|
||||
Text(
|
||||
text = titles.getOrNull(pagerState.currentPage) ?: "",
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
if (pagerState.currentPage == 0) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.End
|
||||
) {
|
||||
TrackerPillButton(
|
||||
label = translation["import_button"],
|
||||
icon = Icons.Default.FolderOpen,
|
||||
onClick = { showImportDialog = true }
|
||||
)
|
||||
TrackerPillButton(
|
||||
label = translation["export_button"],
|
||||
icon = Icons.Default.SaveAlt,
|
||||
onClick = { showExportDialog = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
TrackerPillButton(
|
||||
label = translation["import_button"],
|
||||
icon = Icons.Default.FolderOpen,
|
||||
onClick = { showImportDialog = true }
|
||||
)
|
||||
TrackerPillButton(
|
||||
label = translation["export_button"],
|
||||
icon = Icons.Default.SaveAlt,
|
||||
onClick = { showExportDialog = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
@@ -796,6 +792,22 @@ class FriendTrackerManagerRoot : Routes.Route() {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = { nav ->
|
||||
val themeId by produceState(
|
||||
initialValue = context.config.root.global.uiSettings.managerTheme.get()
|
||||
) {
|
||||
while (true) {
|
||||
delay(300)
|
||||
value = context.config.root.global.uiSettings.managerTheme.get()
|
||||
}
|
||||
}
|
||||
key(themeId) {
|
||||
with(ManagerTheme.fromId(themeId).theme) {
|
||||
this@FriendTrackerManagerRoot.FriendTrackerScreen(nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -2,27 +2,16 @@ package me.eternal.purrfectsnap.ui.util
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.Settings
|
||||
import androidx.compose.animation.core.Easing
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.interaction.InteractionSource
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
/**
|
||||
* Returns true if the user has disabled animator duration scale at the system level.
|
||||
* This respects Accessibility/Developer settings where motion is reduced or disabled.
|
||||
*/
|
||||
fun prefersReducedMotion(context: Context): Boolean {
|
||||
return runCatching {
|
||||
@@ -37,9 +26,8 @@ fun prefersReducedMotion(context: Context): Boolean {
|
||||
|
||||
@Composable
|
||||
fun rememberPrefersReducedMotion(): Boolean {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val context = LocalContext.current
|
||||
val state = remember { mutableStateOf(prefersReducedMotion(context)) }
|
||||
// One-shot read; if you need to observe changes live, add a ContentObserver.
|
||||
LaunchedEffect(Unit) {
|
||||
state.value = prefersReducedMotion(context)
|
||||
}
|
||||
@@ -47,18 +35,34 @@ fun rememberPrefersReducedMotion(): Boolean {
|
||||
}
|
||||
|
||||
object Motion {
|
||||
/**
|
||||
* Standard scroll distance (in pixels) for the header to complete its morphing animation.
|
||||
*/
|
||||
const val HEADER_MORPH_THRESHOLD = 300f
|
||||
|
||||
/**
|
||||
* Milliseconds per pixel for marquee scrolling speed.
|
||||
*/
|
||||
const val MARQUEE_CADENCE = 20
|
||||
|
||||
// 1. High-end Spring Physics for "Alive" UI
|
||||
val springDynamic = spring<Float>(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow
|
||||
)
|
||||
|
||||
@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 d = if (reduced) 0 else durationMillis
|
||||
return tween<Int>(durationMillis = d, easing = easing)
|
||||
return tween(durationMillis = d, easing = easing)
|
||||
}
|
||||
|
||||
@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 d = if (reduced) 0 else durationMillis
|
||||
return tween<Float>(durationMillis = d, easing = easing)
|
||||
return tween(durationMillis = d, easing = easing)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -68,23 +72,23 @@ object Motion {
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a subtle scale-down on press for clickable components (cards, buttons, tiles).
|
||||
* Pass the same [interactionSource] into the clickable component to synchronize state.
|
||||
* Kinetic scale-down on press using spring physics.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.scaleOnPress(
|
||||
interactionSource: InteractionSource,
|
||||
enabled: Boolean = true,
|
||||
scaleDown: Float = 0.98f
|
||||
scaleDown: Float = 0.96f
|
||||
): Modifier {
|
||||
val pressed by interactionSource.collectIsPressedAsState()
|
||||
val target = if (pressed) scaleDown else 1f
|
||||
val spec = Motion.tweenFloatSpec(150)
|
||||
val animated by androidx.compose.animation.core.animateFloatAsState(
|
||||
|
||||
val animated by animateFloatAsState(
|
||||
targetValue = target,
|
||||
animationSpec = spec,
|
||||
animationSpec = Motion.springDynamic,
|
||||
label = "pressScale"
|
||||
)
|
||||
|
||||
return this.then(Modifier.graphicsLayer {
|
||||
scaleX = animated
|
||||
scaleY = animated
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -247,7 +247,7 @@
|
||||
"about_tagline": "An Xposed Module meant to enhance your Snapchat experience!",
|
||||
"about_lead_developers_title": "Lead Developers",
|
||||
"about_story_title": "Our Story",
|
||||
"about_story": "PurrfectSnap was founded on 2nd of October, 2025, as a fork of SnapEnhance by ΞTΞRNAL with a vision to provide users the quality Snapchat experience they deserve. This app was just meant to be a minor update in the SnapEnhance repository, but it soon became a separate app wherein the contributors kept adding features. Then the developer <RSR/> joined the team, and this app soon became a huge success. 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_magic_toast": "Tap 5 times in this screen to see some magic 😉!",
|
||||
"github_button": "GitHub",
|
||||
@@ -264,6 +264,8 @@
|
||||
"read_logs_failed_toast": "Failed to read logs!"
|
||||
},
|
||||
"home_settings": {
|
||||
"ui_theme_title": "UI Theme",
|
||||
"settings_ui_theme": "Aphelion Theme",
|
||||
"actions_title": "Actions",
|
||||
"message_logger_title": "Message Logger",
|
||||
"debug_title": "Debug",
|
||||
|
||||
@@ -70,6 +70,7 @@ class Global : ConfigContainer() {
|
||||
inner class UISettings : ConfigContainer() {
|
||||
val hapticFeedback = boolean("haptic_feedback", true)
|
||||
val useSystemToasts = boolean("use_system_toasts", false)
|
||||
val managerTheme = unique("manager_theme", "LEGACY", "APHELION") { requireRestart() }.apply { set("LEGACY") }
|
||||
}
|
||||
|
||||
val updateSettings = container("update_settings", UpdateSettings()) { addFlags(ConfigFlag.HIDDEN) }
|
||||
|
||||
Reference in New Issue
Block a user