@file:OptIn(androidx.compose.animation.ExperimentalAnimationApi::class) package me.eternal.purrfect.ui.setup import android.app.Activity import android.content.Intent import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints 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.fillMaxHeight 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.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos import androidx.compose.material.icons.filled.AutoAwesome import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Flag import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.SmartToy import androidx.compose.material.icons.filled.VerifiedUser import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.Icon import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView 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.core.view.WindowCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import me.eternal.purrfect.RemoteSideContext import me.eternal.purrfect.SharedContextHolder import me.eternal.purrfect.common.TargetApp import me.eternal.purrfect.common.ui.AppMaterialTheme import me.eternal.purrfect.ui.manager.ManagerAssistantDialog import me.eternal.purrfect.ui.manager.Routes import me.eternal.purrfect.ui.manager.components.AestheticDialog import me.eternal.purrfect.common.ui.theme.LocalPurrfectSkin import me.eternal.purrfect.common.ui.theme.AphelionSkinProvider import me.eternal.purrfect.ui.manager.theme.PurrfectPalette import me.eternal.purrfect.common.ui.util.G2RoundedRectangle import me.eternal.purrfect.ui.setup.screens.LocalSetupScrollState import me.eternal.purrfect.ui.setup.screens.LocalSetupViewportHeight import me.eternal.purrfect.ui.setup.screens.SetupScreen import me.eternal.purrfect.ui.setup.screens.impl.InstallModeScreen import me.eternal.purrfect.ui.setup.screens.impl.InstallMode import me.eternal.purrfect.ui.setup.screens.impl.MappingsScreen import me.eternal.purrfect.ui.setup.screens.impl.PermissionsScreen import me.eternal.purrfect.ui.setup.screens.impl.PickLanguageScreen import me.eternal.purrfect.ui.setup.screens.impl.PatchSnapchatScreen import me.eternal.purrfect.ui.setup.screens.impl.RootInstallSnapchatScreen import me.eternal.purrfect.ui.setup.screens.impl.SaveFolderScreen import me.eternal.purrfect.ui.setup.screens.impl.IntroShowcaseScreen import me.eternal.purrfect.ui.setup.screens.impl.ThemeSelectorScreen import me.eternal.purrfect.ui.setup.screens.impl.parseSetupTargetApps import me.eternal.purrfect.ui.setup.screens.impl.toSetupTargetPrefsValue import me.eternal.purrfect.ui.util.ActivityLauncherHelper import me.eternal.purrfect.ui.util.scaleOnPress import kotlinx.coroutines.delay private const val SETUP_SELECTED_APPS_PREF = SetupPreferences.PROGRESS_SELECTED_TARGET_APPS_PREF private data class SetupStepMeta( val route: String, val title: String, val subtitle: String, val icon: ImageVector ) class SetupActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val setupContext = SharedContextHolder.remote(this).apply { activity = this@SetupActivity } fun endActivity() { setupContext.reload() sendBroadcast(Intent("me.eternal.purrfect.RESTART")) finish() } val requirements = intent.getIntExtra("requirements", Requirements.FIRST_RUN) val isRedditRepatchFlow = requirements and Requirements.REDDIT_REPATCH == Requirements.REDDIT_REPATCH val isSnapchatInstallFlow = requirements and Requirements.INSTALL_SNAPCHAT == Requirements.INSTALL_SNAPCHAT val isRedditInstallFlow = requirements and Requirements.INSTALL_REDDIT == Requirements.INSTALL_REDDIT val isRedditUpdateFlow = requirements and Requirements.UPDATE_REDDIT == Requirements.UPDATE_REDDIT val isTargetInstallFlow = isSnapchatInstallFlow || isRedditInstallFlow || isRedditUpdateFlow val targetInstallApps = when { isSnapchatInstallFlow -> setOf(TargetApp.SNAPCHAT) isRedditInstallFlow || isRedditUpdateFlow -> setOf(TargetApp.REDDIT) else -> emptySet() } val setupPrefs = setupContext.sharedPreferences val setupRoutes = Routes(setupContext).apply { activityLauncher = ActivityLauncherHelper(this@SetupActivity) } fun hasRequirement(requirement: Int) = requirements and requirement == requirement val wasInProgress = setupPrefs.getBoolean("setup_in_progress", false) val isFirstRunFlow = hasRequirement(Requirements.FIRST_RUN) || wasInProgress val persistedRoute = setupPrefs.getString("setup_current_route", null) val persistedSkipPatch = setupPrefs.getBoolean(SetupPreferences.PROGRESS_SKIP_PATCH_PREF, false) val persistedInstallMode = setupPrefs.getString(SetupPreferences.PROGRESS_INSTALL_MODE_PREF, null) val persistedSelectedApps = setupPrefs.getString(SETUP_SELECTED_APPS_PREF, null) ?: setupPrefs.getString(SetupPreferences.LEGACY_PROGRESS_SELECTED_TARGET_APPS_PREF, null) val storedInstallMode = SetupPreferences.lastInstallModeName(setupPrefs) ?.let { runCatching { InstallMode.valueOf(it) }.getOrNull() } val storedSkipAutoSetup = SetupPreferences.wasAutoSetupSkipped(setupPrefs) val skipPatchChoice = mutableStateOf(if (isTargetInstallFlow) storedSkipAutoSetup else persistedSkipPatch) val installModeChoice = mutableStateOf( runCatching { persistedInstallMode?.let { InstallMode.valueOf(it) } }.getOrNull() ?: if (isTargetInstallFlow && !storedSkipAutoSetup) storedInstallMode else null ) val selectedAppsChoice = mutableStateOf( when { isRedditRepatchFlow -> setOf(TargetApp.REDDIT) isTargetInstallFlow -> targetInstallApps else -> parseSetupTargetApps( persistedSelectedApps, fallback = if (wasInProgress && persistedRoute != null) setOf(TargetApp.SNAPCHAT) else emptySet() ) } ) fun persistProgress( route: String, skipPatch: Boolean, installMode: InstallMode?, selectedApps: Set, inProgress: Boolean = true ) { if (!isFirstRunFlow) return setupPrefs.edit() .putBoolean("setup_in_progress", inProgress) .putString("setup_current_route", route) .putBoolean(SetupPreferences.PROGRESS_SKIP_PATCH_PREF, skipPatch) .putString(SetupPreferences.PROGRESS_INSTALL_MODE_PREF, installMode?.name) .putString(SETUP_SELECTED_APPS_PREF, selectedApps.toSetupTargetPrefsValue()) .commit() } fun clearProgress() { setupPrefs.edit() .remove("setup_in_progress") .remove("setup_current_route") .remove(SetupPreferences.PROGRESS_SKIP_PATCH_PREF) .remove(SetupPreferences.PROGRESS_INSTALL_MODE_PREF) .remove(SETUP_SELECTED_APPS_PREF) .remove(SetupPreferences.LEGACY_PROGRESS_SELECTED_TARGET_APPS_PREF) .commit() } val requiredScreens = mutableListOf().apply { if (isFirstRunFlow) { add(ThemeSelectorScreen().apply { route = "themeSelector" }) } if (isFirstRunFlow || hasRequirement(Requirements.LANGUAGE)) { add(PickLanguageScreen().apply { route = "language" }) if (isFirstRunFlow) { add(IntroShowcaseScreen( selectedAppsProvider = { selectedAppsChoice.value }, onSelectionChanged = { selectedAppsChoice.value = it } ).apply { route = "introShowcase" }) } if (isFirstRunFlow) { add(InstallModeScreen( onModeChosen = { mode -> installModeChoice.value = mode skipPatchChoice.value = false }, onSkipAutoSetup = { skipPatchChoice.value = true installModeChoice.value = null } ).apply { route = "installMode" }) } } if (isTargetInstallFlow && (installModeChoice.value == null || skipPatchChoice.value)) { add(InstallModeScreen( onModeChosen = { mode -> installModeChoice.value = mode skipPatchChoice.value = false }, onSkipAutoSetup = { skipPatchChoice.value = true installModeChoice.value = null }, allowSkip = false ).apply { route = "installMode" }) } if (isFirstRunFlow || isTargetInstallFlow) { add(RootInstallSnapchatScreen( selectedAppsProvider = { selectedAppsChoice.value }, allowInstalledTarget = isRedditUpdateFlow ).apply { route = "rootInstallSnapchat" }) add(PatchSnapchatScreen( selectedAppsProvider = { selectedAppsChoice.value }, flow = if (isRedditUpdateFlow) { me.eternal.purrfect.ui.setup.screens.impl.SetupInstallFlow.REPATCH } else { me.eternal.purrfect.ui.setup.screens.impl.SetupInstallFlow.PATCH } ).apply { route = "patchSnapchat" }) } if (isRedditRepatchFlow) { add(PatchSnapchatScreen( selectedAppsProvider = { setOf(TargetApp.REDDIT) }, flow = me.eternal.purrfect.ui.setup.screens.impl.SetupInstallFlow.REPATCH ).apply { route = "repatchReddit" }) } if (isFirstRunFlow || hasRequirement(Requirements.GRANT_PERMISSIONS) || isSnapchatInstallFlow) { add(PermissionsScreen( selectedAppsProvider = { selectedAppsChoice.value } ).apply { route = "permissions" }) } if (isFirstRunFlow || hasRequirement(Requirements.SAVE_FOLDER) || isSnapchatInstallFlow) { add(SaveFolderScreen().apply { route = "saveFolder" }) } if (isFirstRunFlow || hasRequirement(Requirements.MAPPINGS) || isSnapchatInstallFlow) { add(MappingsScreen().apply { route = "mappings" }) } } if (requiredScreens.isEmpty()) { endActivity() return } requiredScreens.forEach { screen -> screen.context = setupContext screen.isFirstRunFlow = isFirstRunFlow screen.init() } if (!isFirstRunFlow && !isTargetInstallFlow) { clearProgress() skipPatchChoice.value = false installModeChoice.value = null selectedAppsChoice.value = emptySet() } setContent { val context = LocalContext.current AphelionSkinProvider(context = context) { val skin = LocalPurrfectSkin.current val translation = setupContext.translation val navController = rememberNavController() var canGoNext by remember { mutableStateOf(false) } var lastRoute by rememberSaveable { mutableStateOf("") } var currentRoute by rememberSaveable { mutableStateOf( persistedRoute?.takeIf { route -> requiredScreens.any { it.route == route } } ?: requiredScreens.first().route ) } var skipAction by remember { mutableStateOf<(() -> Unit)?>(null) } var skipLabel by remember { mutableStateOf(null) } val skipPatch by rememberSaveable { skipPatchChoice } val installMode by installModeChoice val selectedApps by selectedAppsChoice val shouldShowAbiWarning = remember { val deviceIsArm64 = Build.SUPPORTED_ABIS.any { it == "arm64-v8a" || it.startsWith("arm64") } val libDir = context.applicationInfo.nativeLibraryDir.orEmpty() val appIsArm64 = libDir.contains("arm64") deviceIsArm64 && !appIsArm64 } if (shouldShowAbiWarning) { AestheticDialog( onDismissRequest = {}, title = translation["setup.activity.wrong_apk_title"], text = "", icon = Icons.Filled.Warning, confirmButtonText = translation["setup.activity.close_button"], onConfirm = { (context as? Activity)?.finishAffinity() }, showCloseButton = false, opaque = true, customContent = { Text( text = translation["setup.activity.wrong_apk_message"], color = PurrfectPalette.textSecondary, lineHeight = 18.sp ) } ) } val visibleScreens = remember(skipPatch, installMode, selectedApps) { requiredScreens.filterNot { screen -> if (!isRedditRepatchFlow && skipPatch && (screen is PatchSnapchatScreen || screen is RootInstallSnapchatScreen)) { return@filterNot true } if (!isRedditRepatchFlow && installMode == null && (screen is PatchSnapchatScreen || screen is RootInstallSnapchatScreen)) { return@filterNot true } if (!isRedditRepatchFlow && installMode == InstallMode.ROOT && screen is PatchSnapchatScreen) { return@filterNot true } if (installMode == InstallMode.NON_ROOT && screen is RootInstallSnapchatScreen) { return@filterNot true } if (selectedApps.isNotEmpty() && TargetApp.SNAPCHAT !in selectedApps && screen is MappingsScreen) { return@filterNot true } if (selectedApps == setOf(TargetApp.REDDIT) && screen is SaveFolderScreen) { return@filterNot true } false } } val stepMeta = remember(skipPatch, installMode, selectedApps) { visibleScreens.map { it.meta(setupContext) } } val currentStepIndex = visibleScreens.indexOfFirst { it.route == currentRoute }.let { if (it == -1) 0 else it } val animatedProgress by animateFloatAsState( targetValue = (currentStepIndex + 1f) / stepMeta.size.toFloat(), label = "SetupProgress" ) LaunchedEffect(skipPatch, installMode, selectedApps) { val adjustedRoute = visibleScreens.firstOrNull { it.route == currentRoute }?.route ?: run { val currentIndex = requiredScreens.indexOfFirst { it.route == currentRoute }.coerceAtLeast(0) val nextVisible = requiredScreens.drop(currentIndex + 1) .firstOrNull { screen -> visibleScreens.any { it.route == screen.route } } nextVisible?.route ?: visibleScreens.firstOrNull()?.route } adjustedRoute?.let { if (it != currentRoute) currentRoute = it } } LaunchedEffect(currentRoute, skipPatch, installMode, selectedApps) { persistProgress(currentRoute, skipPatch, installMode, selectedApps, true) if (navController.currentDestination?.route != currentRoute) { navController.navigate(currentRoute) { popUpTo(requiredScreens.first().route) { inclusive = false } launchSingleTop = true } } if (lastRoute != currentRoute) { canGoNext = false skipAction = null skipLabel = null lastRoute = currentRoute } } fun nextScreen() { if (!canGoNext) return canGoNext = false val currentScreen = visibleScreens.getOrNull(currentStepIndex) val proceed = { currentScreen?.onLeave() if (currentStepIndex < visibleScreens.lastIndex) { val nextRoute = visibleScreens[currentStepIndex + 1].route currentRoute = nextRoute } else { val preferredTarget = listOf(TargetApp.SNAPCHAT, TargetApp.REDDIT, TargetApp.WHATSAPP, TargetApp.INSTAGRAM) .firstOrNull { it in selectedApps } if (isFirstRunFlow || isTargetInstallFlow) { SetupPreferences.saveSetupChoices( setupPrefs, selectedApps = selectedApps, installModeName = installMode?.name, skippedAutoSetup = skipPatch ) } if (isFirstRunFlow || (isTargetInstallFlow && !skipPatch)) { SetupPreferences.addCompletedTargets(setupPrefs, selectedApps) } if (isFirstRunFlow || !skipPatch) { preferredTarget?.let { setupContext.setActiveTargetApp(it) } } clearProgress() endActivity() } } currentScreen?.onNext { proceed() } ?: proceed() } AppMaterialTheme { val view = LocalView.current val navBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() var setupAiPrompt by rememberSaveable { mutableStateOf(null) } SideEffect { val window = (view.context as Activity).window WindowCompat.setDecorFitsSystemWindows(window, false) @Suppress("DEPRECATION") window.statusBarColor = Color.Transparent.toArgb() @Suppress("DEPRECATION") window.navigationBarColor = Color.Transparent.toArgb() val insetsController = WindowInsetsControllerCompat(window, window.decorView) insetsController.isAppearanceLightStatusBars = false insetsController.isAppearanceLightNavigationBars = false } Box( modifier = Modifier .fillMaxSize() .background(Color.Transparent) ) { SetupAuroraBackground() SetupTopBar(onAskAi = { setupAiPrompt = "hi" }) val bottomPadding = 80.dp + navBarPadding Column( modifier = Modifier .fillMaxSize() .padding( start = 16.dp, end = 16.dp, top = 98.dp, bottom = bottomPadding ) .statusBarsPadding(), verticalArrangement = Arrangement.spacedBy(14.dp), horizontalAlignment = Alignment.CenterHorizontally ) { SetupHeader( currentStep = stepMeta[currentStepIndex], currentIndex = currentStepIndex, total = stepMeta.size ) SetupProgressBar(animatedProgress) Box( modifier = Modifier .fillMaxWidth() .weight(1f) .padding(horizontal = 4.dp) ) { NavHost( navController = navController, startDestination = requiredScreens.first().route, enterTransition = { fadeIn() }, exitTransition = { fadeOut() }, popEnterTransition = { fadeIn() }, popExitTransition = { fadeOut() } ) { requiredScreens.forEach { screen -> val screenRoute = screen.route screen.allowNext = allowNext@{ canGoNextFlag -> if (screenRoute != currentRoute) return@allowNext canGoNext = canGoNextFlag } screen.goNext = goNext@{ if (screenRoute != currentRoute) return@goNext canGoNext = true nextScreen() } screen.showSkip = { label, action -> if (screenRoute == currentRoute) { skipLabel = label skipAction = action } } composable( screen.route, enterTransition = { slideInHorizontally { it } }, exitTransition = { slideOutHorizontally { -it } }, popEnterTransition = { slideInHorizontally { -it } }, popExitTransition = { slideOutHorizontally { it } } ) { BackHandler(true) {} Box( modifier = Modifier .fillMaxSize() .padding(horizontal = 10.dp, vertical = 4.dp), contentAlignment = Alignment.Center ) { val scrollState = remember(screenRoute) { ScrollState(0) } LaunchedEffect(screenRoute) { scrollState.scrollTo(0) } BoxWithConstraints( modifier = Modifier .widthIn(max = 560.dp) .fillMaxHeight() ) { CompositionLocalProvider( LocalSetupScrollState provides scrollState, LocalSetupViewportHeight provides maxHeight ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { screen.Content() } } } } } } } } } Row( modifier = Modifier .align(Alignment.BottomCenter) .navigationBarsPadding() .padding(bottom = 16.dp) .padding(horizontal = 24.dp), horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically ) { if (skipAction != null) { SkipButton( label = skipLabel ?: "Skip", onClick = { skipAction?.invoke() } ) } NextButton( enabled = canGoNext, isFinalStep = currentStepIndex >= stepMeta.lastIndex, onClick = { nextScreen() } ) } setupAiPrompt?.let { prompt -> ManagerAssistantDialog( context = setupContext, routes = setupRoutes, initialUserMessage = prompt, onDismiss = { setupAiPrompt = null } ) } } } } } } @Composable private fun SkipButton( label: String, onClick: () -> Unit ) { val skin = LocalPurrfectSkin.current val interactionSource = remember { MutableInteractionSource() } val gradient = Brush.horizontalGradient( listOf( skin.glowSecondary, skin.glowPrimary ) ) val contentColor = if (skin.glowPrimary.luminance() > 0.5f) Color.Black.copy(alpha = 0.85f) else Color.White val buttonShape = if (skin.id == "AETHER") G2RoundedRectangle(36.dp) else RoundedCornerShape(36.dp) Surface( modifier = Modifier .scaleOnPress(interactionSource) .clip(buttonShape) .border( width = 1.dp, color = skin.textPrimary.copy(alpha = 0.24f), shape = buttonShape ) .clickable( interactionSource = interactionSource, indication = null ) { onClick() }, color = Color.Transparent ) { Box( modifier = Modifier .background(gradient) .padding(horizontal = 24.dp, vertical = 14.dp), contentAlignment = Alignment.Center ) { Text( text = label, color = contentColor, fontWeight = FontWeight.SemiBold, fontSize = 15.sp ) } } } private fun SetupScreen.meta(context: RemoteSideContext): SetupStepMeta { val translation = context.translation return when (this) { is ThemeSelectorScreen -> SetupStepMeta( route = route, title = "App Theme", subtitle = "Choose your Aphelion skin", icon = Icons.Filled.Palette ) is PickLanguageScreen -> SetupStepMeta( route = route, title = translation["setup.dialogs.select_language"], subtitle = translation["setup.activity.language_subtitle"], icon = Icons.Filled.Language ) is IntroShowcaseScreen -> SetupStepMeta( route = route, title = "Supported apps", subtitle = "Choose Snapchat, Reddit, or both", icon = Icons.Filled.AutoAwesome ) is InstallModeScreen -> SetupStepMeta( route = route, title = translation["setup.activity.install_mode_title"], subtitle = translation["setup.activity.install_mode_subtitle"], icon = Icons.Filled.VerifiedUser ) is PermissionsScreen -> SetupStepMeta( route = route, title = translation["setup.permissions.dialog"], subtitle = translation["setup.activity.permissions_subtitle"], icon = Icons.Filled.VerifiedUser ) is PatchSnapchatScreen -> SetupStepMeta( route = route, title = translation["setup.activity.patch_title"], subtitle = translation["setup.activity.patch_subtitle"], icon = Icons.Filled.Download ) is RootInstallSnapchatScreen -> SetupStepMeta( route = route, title = translation["setup.activity.root_install_title"], subtitle = translation["setup.activity.root_install_subtitle"], icon = Icons.Filled.Download ) is SaveFolderScreen -> SetupStepMeta( route = route, title = translation["setup.dialogs.save_folder"], subtitle = translation["setup.activity.save_folder_subtitle"], icon = Icons.Filled.Folder ) is MappingsScreen -> SetupStepMeta( route = route, title = translation["setup.mappings.dialog"], subtitle = translation["setup.activity.mappings_subtitle"], icon = Icons.Filled.AutoAwesome ) else -> SetupStepMeta(route, route, route, Icons.Filled.Check) } } @Composable private fun SetupAuroraBackground() { val skin = LocalPurrfectSkin.current val infiniteTransition = rememberInfiniteTransition(label = "setupAurora") val driftX by infiniteTransition.animateFloat( initialValue = -90f, targetValue = 140f, animationSpec = infiniteRepeatable( animation = tween(durationMillis = 16000, easing = LinearEasing), repeatMode = RepeatMode.Reverse ), label = "driftX" ) val driftY by infiniteTransition.animateFloat( initialValue = 60f, targetValue = -120f, animationSpec = infiniteRepeatable( animation = tween(durationMillis = 14000, easing = LinearEasing), repeatMode = RepeatMode.Reverse ), label = "driftY" ) Box( modifier = Modifier .fillMaxSize() .background(skin.backgroundGradient) ) { Canvas(modifier = Modifier.fillMaxSize()) { val primaryGlow = skin.glowPrimary.copy(alpha = 0.36f) val secondaryGlow = skin.glowSecondary.copy(alpha = 0.28f) drawRect( brush = Brush.linearGradient( colors = listOf( skin.textPrimary.copy(alpha = 0.06f), Color.Transparent ), start = Offset(x = size.width * 0.15f, y = 0f), end = Offset(x = size.width * 0.75f, y = size.height * 0.6f) ), size = this.size ) drawRect( brush = Brush.linearGradient( colors = listOf( skin.glowPrimary.copy(alpha = 0.12f), Color.Transparent ), start = Offset(x = 0f, y = size.height * 0.72f), end = Offset(x = size.width, y = size.height) ), size = this.size ) } } } @Composable private fun SetupTopBar(onAskAi: () -> Unit) { val skin = LocalPurrfectSkin.current val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() val contentColor = if (skin.isDark) Color.White else Color.Black.copy(alpha = 0.85f) Surface( modifier = Modifier .fillMaxWidth() .padding(horizontal = 14.dp, vertical = 12.dp) .padding(top = topPadding), shape = if (skin.id == "AETHER") G2RoundedRectangle(28.dp) else RoundedCornerShape(28.dp), color = skin.glassSurface, tonalElevation = 0.dp, shadowElevation = 0.dp, border = androidx.compose.foundation.BorderStroke( 1.dp, Brush.linearGradient( listOf( skin.glowPrimary.copy(alpha = 0.6f), skin.glowSecondary.copy(alpha = 0.45f) ) ) ) ) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 18.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp) ) { Text( text = "Purrfect", color = skin.textPrimary, fontWeight = FontWeight.ExtraBold, fontSize = 18.sp, modifier = Modifier.weight(1f) ) Surface( shape = RoundedCornerShape(40), color = skin.textPrimary.copy(alpha = 0.08f), border = androidx.compose.foundation.BorderStroke(1.dp, skin.textPrimary.copy(alpha = 0.14f)) ) { Row( modifier = Modifier .clip(RoundedCornerShape(40)) .clickable(onClick = onAskAi) .padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Icon( imageVector = Icons.Filled.SmartToy, contentDescription = null, tint = contentColor ) Text( text = "Ask AI", color = contentColor, fontWeight = FontWeight.Medium, fontSize = 13.sp ) } } } } } @Composable private fun SetupHeader( currentStep: SetupStepMeta, currentIndex: Int, total: Int ) { val skin = LocalPurrfectSkin.current val translation = SharedContextHolder.remote(LocalContext.current).translation val contentColor = if (skin.isDark) Color.White else Color.Black.copy(alpha = 0.85f) Column( verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { Surface( shape = RoundedCornerShape(30), color = skin.textPrimary.copy(alpha = 0.08f), border = androidx.compose.foundation.BorderStroke(1.dp, skin.textPrimary.copy(alpha = 0.14f)) ) { Row( modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Icon( imageVector = Icons.Filled.Flag, contentDescription = null, tint = skin.glowPrimary ) Text( text = translation.format( "setup.activity.step_counter", "current" to (currentIndex + 1).toString(), "total" to total.toString() ), color = contentColor, fontWeight = FontWeight.Medium, fontSize = 13.sp ) } } Surface( shape = RoundedCornerShape(30), color = skin.glowPrimary.copy(alpha = 0.16f), border = androidx.compose.foundation.BorderStroke(1.dp, skin.glowPrimary.copy(alpha = 0.35f)) ) { Row( modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Icon( imageVector = currentStep.icon, contentDescription = null, tint = skin.glowPrimary ) Text( text = currentStep.title, color = contentColor, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis ) } } } } } private enum class StepState { COMPLETE, ACTIVE, UPCOMING } @Composable private fun StepBadgesRow(steps: List, currentStep: Int) { val skin = LocalPurrfectSkin.current Row( horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth() ) { steps.forEachIndexed { index, step -> val state = when { index < currentStep -> StepState.COMPLETE index == currentStep -> StepState.ACTIVE else -> StepState.UPCOMING } StepBadge(step, state) if (index < steps.lastIndex) { Box( modifier = Modifier .height(3.dp) .width(28.dp) .background( Brush.horizontalGradient( listOf( skin.textPrimary.copy(alpha = 0.05f), skin.glowPrimary.copy(alpha = 0.4f), skin.textPrimary.copy(alpha = 0.05f) ) ), shape = if (skin.id == "AETHER") G2RoundedRectangle(20.dp) else RoundedCornerShape(20.dp) ) ) } } } } @Composable private fun StepBadge(step: SetupStepMeta, state: StepState) { val skin = LocalPurrfectSkin.current val translation = SharedContextHolder.remote(LocalContext.current).translation val baseColor = when (state) { StepState.COMPLETE -> skin.glowSecondary StepState.ACTIVE -> skin.glowPrimary StepState.UPCOMING -> skin.textPrimary.copy(alpha = 0.35f) } val background = when (state) { StepState.UPCOMING -> skin.textPrimary.copy(alpha = 0.05f) StepState.COMPLETE -> skin.textPrimary.copy(alpha = 0.08f) StepState.ACTIVE -> skin.textPrimary.copy(alpha = 0.12f) } Surface( shape = if (skin.id == "AETHER") G2RoundedRectangle(18.dp) else RoundedCornerShape(18.dp), color = background, border = androidx.compose.foundation.BorderStroke( 1.dp, baseColor.copy(alpha = 0.5f) ) ) { Row( modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp) ) { Surface( modifier = Modifier .size(32.dp) .clip(CircleShape), color = baseColor.copy(alpha = 0.22f) ) { Box(contentAlignment = Alignment.Center) { Icon( imageVector = step.icon, contentDescription = null, tint = if (state == StepState.UPCOMING) skin.textPrimary.copy(alpha = 0.5f) else skin.textPrimary, modifier = Modifier.align(Alignment.Center) ) } } Column( verticalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.widthIn(min = 0.dp, max = 160.dp) ) { Text( text = step.title, color = skin.textPrimary, fontWeight = if (state == StepState.ACTIVE) FontWeight.Bold else FontWeight.SemiBold, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis ) val hint = when (state) { StepState.COMPLETE -> translation["setup.activity.step_complete"] StepState.ACTIVE -> translation["setup.activity.step_active"] StepState.UPCOMING -> translation["setup.activity.step_upcoming"] } Text( text = hint, color = skin.textSecondary.copy(alpha = 0.7f), fontSize = 11.sp ) } } } } @Composable private fun SetupProgressBar(progress: Float) { val skin = LocalPurrfectSkin.current val gradient = Brush.horizontalGradient( listOf( skin.glowSecondary, skin.glowPrimary ) ) Surface( shape = if (skin.id == "AETHER") G2RoundedRectangle(18.dp) else RoundedCornerShape(18.dp), color = skin.textPrimary.copy(alpha = 0.08f), border = androidx.compose.foundation.BorderStroke( 1.dp, skin.textPrimary.copy(alpha = 0.12f) ) ) { Box( modifier = Modifier .fillMaxWidth() .height(14.dp) ) { Box( modifier = Modifier .fillMaxHeight() .fillMaxWidth(progress.coerceIn(0f, 1f)) .background(gradient) ) } } } @Composable private fun SetupContentCard( modifier: Modifier = Modifier, content: @Composable () -> Unit ) { val skin = LocalPurrfectSkin.current val cardShape = if (skin.id == "AETHER") G2RoundedRectangle(34.dp) else RoundedCornerShape(34.dp) Surface( modifier = modifier, shape = cardShape, color = if (skin.id == "AETHER") skin.cardOverlayColor else skin.glassSurface, border = androidx.compose.foundation.BorderStroke( 1.dp, Brush.linearGradient( listOf( skin.glowPrimary.copy(alpha = 0.45f), skin.glowSecondary.copy(alpha = 0.35f) ) ) ), shadowElevation = 0.dp, tonalElevation = 0.dp ) { Box( modifier = Modifier .background(if (skin.id == "AETHER") skin.cardOverlayColor else skin.cardOverlayColor.copy(alpha = 0.28f)) .padding(horizontal = 16.dp, vertical = 16.dp) ) { content() } } } @Composable private fun NextButton( enabled: Boolean, isFinalStep: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier ) { val skin = LocalPurrfectSkin.current val translation = SharedContextHolder.remote(LocalContext.current).translation val alpha by animateFloatAsState(targetValue = if (enabled) 1f else 0.6f, label = "NextButtonAlpha") val gradient = Brush.horizontalGradient( listOf( skin.glowSecondary, skin.glowPrimary ) ) val interactionSource = remember { MutableInteractionSource() } val contentColor = if (enabled) { if (skin.glowPrimary.luminance() > 0.5f) Color.Black.copy(alpha = 0.85f) else Color.White } else { if (skin.isDark) Color.White else Color.Black.copy(alpha = 0.85f) } Surface( modifier = modifier .alpha(alpha) .scaleOnPress(interactionSource) .clip(RoundedCornerShape(36.dp)) .border( width = 1.dp, color = skin.textPrimary.copy(alpha = 0.24f), shape = RoundedCornerShape(36.dp) ) .clickable( enabled = enabled, interactionSource = interactionSource, indication = null ) { onClick() }, color = Color.Transparent ) { Box( modifier = Modifier .background(if (enabled) gradient else SolidColor(skin.textPrimary.copy(alpha = 0.08f))) .padding(horizontal = 24.dp, vertical = 14.dp) ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp) ) { Text( text = if (isFinalStep) { translation["setup.activity.finish_button"] } else { translation["setup.activity.continue_button"] }, color = contentColor, fontWeight = FontWeight.SemiBold, fontSize = 15.sp ) Icon( imageVector = if (isFinalStep) Icons.Filled.Check else Icons.AutoMirrored.Filled.ArrowForwardIos, contentDescription = null, tint = contentColor, modifier = Modifier.size(22.dp) ) } } } } }