package me.eternal.purrfect.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.SpanStyle import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp 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.purrfect.R import me.eternal.purrfect.common.BuildConfig import me.eternal.purrfect.common.TargetApp import me.eternal.purrfect.common.ui.rememberAsyncMutableState import me.eternal.purrfect.common.ui.rememberAsyncMutableStateList import me.eternal.purrfect.common.util.ktx.openLink import me.eternal.purrfect.storage.getQuickTiles import me.eternal.purrfect.storage.setQuickTiles import me.eternal.purrfect.ui.manager.components.AestheticDialog import me.eternal.purrfect.ui.manager.data.UpdateDownloader import me.eternal.purrfect.ui.manager.data.Updater import me.eternal.purrfect.ui.manager.data.Updater.Channel import me.eternal.purrfect.ui.manager.ManagerAssistantEntry import me.eternal.purrfect.ui.manager.ManagerAssistantTriggerStyle import me.eternal.purrfect.ui.manager.pages.home.HomeRootSection import me.eternal.purrfect.ui.manager.pages.home.QuickActionsDialog import me.eternal.purrfect.ui.manager.theme.PurrfectPalette import me.eternal.purrfect.ui.util.Motion import me.eternal.purrfect.ui.util.PurrfectMarqueeText import me.eternal.purrfect.ui.util.headerHeightTracker import me.eternal.purrfect.ui.util.scaleOnPress @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, modifier: Modifier = Modifier, expandedWidth: Dp? = null, collapsedWidth: Dp = 36.dp, haptic: HapticFeedback, onClick: () -> Unit, ) { val targetWidth = expandedWidth?.let { lerp(collapsedWidth, it, shrinkFactor) } Surface( modifier = modifier.height(36.dp).then(if (targetWidth != null) Modifier.width(targetWidth) else Modifier), 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 .fillMaxWidth() .clip(RoundedCornerShape(40)) .clickable { haptic.performHapticFeedback(HapticFeedbackType.LongPress); onClick() } .padding(vertical = 6.dp, horizontal = lerp(7.dp, 10.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.45f) / 0.55f).coerceIn(0f, 1f) if (labelAlpha > 0.02f) { 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.Ellipsis, modifier = Modifier .graphicsLayer { alpha = labelAlpha; translationX = (-4 * (1f - shrinkFactor)).dp.toPx() } .weight(1f, fill = false) ) } } } } } @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) } } ManagerAssistantEntry( context = context, routes = routes, style = ManagerAssistantTriggerStyle.APHELION, shrinkFactor = shrinkFactor, modifier = Modifier.width(lerp(36.dp, 66.dp, shrinkFactor)) ) AphelionTopBarActionChip( icon = Icons.Filled.BugReport, label = context.translation["manager.routes.home_logs"], expandedWidth = 88.dp, shrinkFactor = shrinkFactor, haptic = haptic ) { routes.homeLogs.navigate() } AphelionTopBarActionChip( icon = Icons.Filled.Settings, label = context.translation["manager.routes.home_settings"], expandedWidth = 96.dp, 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, isPurrAuraActive: Boolean, onAboutClick: () -> Unit, onGithubClick: () -> Unit, avenirNext: FontFamily, scrollOffset: () -> Int, haptic: HapticFeedback ) { val heroShape = RoundedCornerShape(36.dp) val gitHashShort = remember { (context.installationSummary.modInfo?.gitHash ?: BuildConfig.GIT_HASH).take(7) } val activeTarget = context.activeTargetApp val isRedditMode = activeTarget == TargetApp.REDDIT val targetAccent = when (activeTarget) { TargetApp.SNAPCHAT -> Color(0xFFFFE100) TargetApp.REDDIT -> Color(0xFFFF4500) TargetApp.WHATSAPP -> Color(0xFF25D366) TargetApp.INSTAGRAM -> Color(0xFFE4405F) } val targetSuffix = when (activeTarget) { TargetApp.SNAPCHAT -> "Snap" TargetApp.REDDIT -> "Reddit" TargetApp.WHATSAPP -> "WA" TargetApp.INSTAGRAM -> "Insta" } val targetName = when (activeTarget) { TargetApp.SNAPCHAT -> "Snapchat" TargetApp.REDDIT -> "Reddit" TargetApp.WHATSAPP -> "WhatsApp" TargetApp.INSTAGRAM -> "Instagram" } 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 = buildAnnotatedString { append("Purrfect") withStyle(SpanStyle(color = targetAccent)) { append(targetSuffix) } }, 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 = (translation["hero_tagline"] ?: "").replace("Snapchat", targetName), 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)) gitHashShort.takeIf { it.isNotBlank() && it.lowercase() != "unknown" }?.let { HeroBadge(translation.format("hero_build_label", "build" to it)) } } if (latestUpdate != null) { val isRedditUpdate = latestUpdate.target == Updater.UpdateTarget.REDDIT val updateTitle = if (isRedditUpdate) { translation.getOrNull("reddit_update_title") ?: "Update Reddit" } else { translation["update_title"] ?: "" } val updateContent = if (isRedditUpdate) { translation.getOrNull("reddit_update_content")?.let { translation.format("reddit_update_content", "version" to latestUpdate.versionName) } ?: translation.format("update_content", "version" to latestUpdate.versionName) } else { translation.format("update_content", "version" to latestUpdate.versionName) } 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(updateTitle, color = Color.White, fontWeight = FontWeight.SemiBold, fontSize = 14.sp) Text(updateContent, 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 ) { if (!context.isLimitedTargetMode) { 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 ) } } } Text( text = "Switch target app in Settings", color = Color.White.copy(alpha = 0.78f), fontSize = 12.sp, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth() ) 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.vercel.app", 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); onGithubClick() }, 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/purrfect_tg", 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 activeTarget = context.activeTargetApp val isRedditMode = activeTarget == TargetApp.REDDIT val activeCards = when (activeTarget) { TargetApp.REDDIT -> redditCards TargetApp.WHATSAPP -> whatsAppCards TargetApp.INSTAGRAM -> instagramCards TargetApp.SNAPCHAT -> cards } val allQuickTileNames = remember(activeCards) { activeCards.keys.map { it.first } } val selectedTiles = rememberAsyncMutableStateList(defaultValue = allQuickTileNames) { if (context.isLimitedTargetMode) return@rememberAsyncMutableStateList 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.coroutineScope.launch(Dispatchers.IO) { context.database.setQuickTiles(allQuickTileNames) prefs.edit().putBoolean(HomeRootSection.QUICK_TILES_INITIALIZED_PREF, true).apply() } allQuickTileNames } } } val latestUpdate by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(isRedditMode)) { if (isRedditMode) { Updater.getLatestRedditUpdate(context.sharedPreferences.getString(Updater.REDDIT_INSTALLED_RELEASE_TAG_PREF, null)) } else { Updater.getLatestRelease(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) } val quickActionsEnabled = true var showChangelogDialog by rememberSaveable { mutableStateOf(false) } var changelogText by rememberSaveable { mutableStateOf(null) } var changelogLoading by remember { mutableStateOf(false) } var changelogError by remember { mutableStateOf(null) } var changelogVersion by remember { mutableStateOf(null) } var showFullChangelogDialog by rememberSaveable { mutableStateOf(false) } var fullChangelogText by rememberSaveable { mutableStateOf(null) } var fullChangelogLoading by remember { mutableStateOf(false) } var fullChangelogError by remember { mutableStateOf(null) } var showAnnouncementsDialog by rememberSaveable { mutableStateOf(false) } var announcementsText by rememberSaveable { mutableStateOf(null) } var announcementsLoading by remember { mutableStateOf(false) } val coroutineScope = rememberCoroutineScope() var controlsHeight by remember { mutableStateOf(100.dp) } LaunchedEffect(scrollState) { androidx.compose.runtime.snapshotFlow { scrollState.value }.collect { routes.navigation?.globalScrollOffset = it } } val handleUpdateAction: () -> Unit = { latestUpdate?.let { latest -> if (latest.target == Updater.UpdateTarget.REDDIT) { launchRedditUpdateSetup() return@let } 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 = "purrfect-${if (abiName == "arm64") "armv8" else "armv7"}-debug" UpdateDownloader.downloadAndInstall(context, "https://nightly.link/${latest.repositoryFullName}/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 loadChangelog() { val targetVersion = latestUpdate?.versionName ?: BuildConfig.VERSION_NAME if (changelogVersion == targetVersion && changelogText != null) return changelogLoading = true changelogError = null coroutineScope.launch(Dispatchers.IO) { runCatching { val body = fetchTextWithFallback(changelogStableUrls) extractChangelogForVersion(body, targetVersion).ifBlank { body.trim() } }.onSuccess { text -> withContext(Dispatchers.Main) { changelogText = text changelogVersion = targetVersion changelogLoading = false } }.onFailure { e -> withContext(Dispatchers.Main) { changelogError = e.message ?: "Failed to fetch" changelogLoading = false } } } } fun loadAnnouncements() { if (announcementsText != null) return announcementsLoading = true coroutineScope.launch(Dispatchers.IO) { runCatching { fetchTextWithFallback(announcementsUrls) } .onSuccess { withContext(Dispatchers.Main) { announcementsText = it; announcementsLoading = false } } .onFailure { withContext(Dispatchers.Main) { announcementsLoading = false } } } } fun loadFullChangelog() { if (fullChangelogText != null) return fullChangelogLoading = true fullChangelogError = null coroutineScope.launch(Dispatchers.IO) { runCatching { fetchTextWithFallback(changelogStableUrls).trim() }.onSuccess { text -> withContext(Dispatchers.Main) { fullChangelogText = text fullChangelogLoading = false } }.onFailure { e -> withContext(Dispatchers.Main) { fullChangelogError = e.message ?: "Failed to fetch" fullChangelogLoading = 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) ) { Row( modifier = Modifier .align(Alignment.Center) .wrapContentWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp) ) { AphelionTopBarActionChip( icon = Icons.Filled.Notifications, label = null, expandedWidth = 52.dp, shrinkFactor = (1f - focusFactor).coerceIn(0f, 1f), contentDescription = translation["announcements_button_description"], haptic = haptic ) { showAnnouncementsDialog = true; loadAnnouncements() } AphelionTopBarActionChip( icon = Icons.Filled.Description, label = null, expandedWidth = 52.dp, shrinkFactor = (1f - focusFactor).coerceIn(0f, 1f), contentDescription = translation.getOrNull("changelog_button_description") ?: "Open full changelog", haptic = haptic ) { showFullChangelogDialog = true; loadFullChangelog() } 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 { if (it.target == Updater.UpdateTarget.REDDIT) { handleUpdateAction() } else { showChangelogDialog = true loadChangelog() } } }, isPurrAuraActive = isPurrAuraActive, onAboutClick = { routes.about.navigate() }, onGithubClick = { openPurrfectRepository(coroutineScope) }, avenirNext = avenirNext, scrollOffset = { scrollState.value }, haptic = haptic ) Spacer(Modifier.height(12.dp)) if (quickActionsEnabled) { 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 = activeCards.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 }, showCloseButton = false, customContent = { Column(modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) { if (announcementsLoading) CircularProgressIndicator(color = Color.White) else Text(announcementsText ?: translation["announcements_dialog_empty"] ?: "", color = PurrfectPalette.textPrimary, fontSize = 14.sp) } } ) } if (showChangelogDialog) { AestheticDialog( onDismissRequest = { showChangelogDialog = false }, title = translation["changelog_dialog_title"] ?: "Changelog", text = "", icon = Icons.Filled.Info, confirmButtonText = translation["changelog_dialog_update_button"] ?: "Update", onConfirm = { haptic.performHapticFeedback(HapticFeedbackType.LongPress); showChangelogDialog = false; handleUpdateAction() }, dismissButtonText = translation["changelog_dialog_cancel_button"] ?: "Cancel", onDismiss = { showChangelogDialog = false }, showCloseButton = false, customContent = { Column(modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) { if (changelogLoading) CircularProgressIndicator(color = Color.White) else if (changelogError != null) Text(changelogError!!, color = Color.Red, fontSize = 14.sp) else Text(changelogText ?: translation["changelog_dialog_empty"] ?: "", color = PurrfectPalette.textPrimary, fontSize = 14.sp) } } ) } if (showFullChangelogDialog) { AestheticDialog( onDismissRequest = { showFullChangelogDialog = false }, title = translation["changelog_dialog_title"] ?: "Changelog", text = "", icon = Icons.Filled.Description, confirmButtonText = translation["announcements_dialog_close_button"] ?: "Close", onConfirm = { showFullChangelogDialog = false }, showCloseButton = false, customContent = { Column( modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp) ) { if (fullChangelogLoading) CircularProgressIndicator(color = Color.White) else if (fullChangelogError != null) Text(fullChangelogError!!, color = Color.Red, fontSize = 14.sp) else Text(fullChangelogText ?: translation["changelog_dialog_empty"] ?: "", color = PurrfectPalette.textPrimary, fontSize = 14.sp) } } ) } if (quickActionsEnabled && showQuickActionsMenu) { QuickActionsDialog( quickActions = activeCards, 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) if (!context.isLimitedTargetMode) { context.coroutineScope.launch { context.database.setQuickTiles(selectedTiles) } } showQuickActionsMenu = false }, translation = translation ) } }