diff --git a/app/src/main/java/io/github/bengidev/opencore/onboarding/CONTEXT.md b/app/src/main/java/io/github/bengidev/opencore/onboarding/CONTEXT.md index 988e70e4..1a5b35ea 100644 --- a/app/src/main/java/io/github/bengidev/opencore/onboarding/CONTEXT.md +++ b/app/src/main/java/io/github/bengidev/opencore/onboarding/CONTEXT.md @@ -2,11 +2,11 @@ | | | |---|---| -| **Context** | Onboarding feature — first-run product tour | +| **Context** | Onboarding feature — first-run cinematic intro | | **Package** | `io.github.bengidev.opencore.onboarding` | | **Module** | Internal module inside `:app` | -First-run product tour with interactive visual demos. Persists completion via DataStore, then returns control to the app shell. +Single-page onboarding with a wireframe cube hero, feature card carousel, and swipe-to-start CTA. Persists completion via DataStore, then returns control to the app shell. ## Visibility @@ -16,8 +16,8 @@ The entire onboarding package is an **internal module**: types default to `inter - **OnboardingComponent**: Decompose component dispatching intents - **OnboardingIntent**: Command objects (Command pattern) -- **OnboardingReducer**: Pure state transitions -- **Pages**: `EncryptedPairing`, `IdeaStudio`, `PromptQueue`, `ReasoningControl`, `WorkspaceReady` +- **OnboardingReducer**: Pure state transitions (`isFinished` only) +- **OnboardingFeature**: Feature catalog for the carousel - **OpenCorePalette**: Graphite monochrome design tokens (OpenCore branding) ## Design patterns @@ -25,18 +25,16 @@ The entire onboarding package is an **internal module**: types default to `inter | Pattern | Location | |---|---| | Command | `OnboardingIntent` | -| Strategy | `PageDemoDefaultsStrategy` | -| Factory Method | `PageDemoDefaultsStrategyFactory`, `PageVisualFactory` | | Repository | `OnboardingRepository` | | Facade | `OnboardingFacade` | ## Flow ``` -EncryptedPairing → IdeaStudio → PromptQueue → ReasoningControl → WorkspaceReady +Cube hero showoff → morph to header → feature carousel → swipe to start → app shell ``` ## Constraints - Onboarding must not store provider credentials or model preferences. -- Only completion is persisted; demo state is local UI state. +- Only completion is persisted; animation and carousel state are local UI state. diff --git a/app/src/main/java/io/github/bengidev/opencore/onboarding/OnboardingScreen.kt b/app/src/main/java/io/github/bengidev/opencore/onboarding/OnboardingScreen.kt index e69c0ea4..1153146b 100644 --- a/app/src/main/java/io/github/bengidev/opencore/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/io/github/bengidev/opencore/onboarding/OnboardingScreen.kt @@ -1,129 +1,74 @@ package io.github.bengidev.opencore.onboarding -import androidx.compose.animation.AnimatedContent +import android.app.Activity +import android.provider.Settings +import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.arkivanov.decompose.extensions.compose.subscribeAsState +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat import io.github.bengidev.opencore.onboarding.application.OnboardingComponent -import io.github.bengidev.opencore.onboarding.presenter.FeaturePageView -import io.github.bengidev.opencore.onboarding.presenter.components.DiagonalHatchPattern -import io.github.bengidev.opencore.onboarding.presenter.components.OnboardingBottomNavigation -import io.github.bengidev.opencore.onboarding.presenter.components.OnboardingTopBar -import io.github.bengidev.opencore.onboarding.presenter.components.PixelGridBackground +import io.github.bengidev.opencore.onboarding.presenter.OnboardingSinglePageScreen import io.github.bengidev.opencore.onboarding.theme.OnboardingTheme import io.github.bengidev.opencore.onboarding.theme.OpenCoreOnboardingTheme -/** Main onboarding screen — layout matched to OpenCoreOnboardingView. */ @Composable internal fun OnboardingScreen( component: OnboardingComponent, darkTheme: Boolean, onThemeToggle: () -> Unit ) { - val state by component.state.subscribeAsState() + val context = LocalContext.current + val reduceMotion = remember(context) { + runCatching { + Settings.Global.getFloat( + context.contentResolver, + Settings.Global.ANIMATOR_DURATION_SCALE, + 1f + ) == 0f + }.getOrDefault(false) + } OpenCoreOnboardingTheme(darkTheme = darkTheme) { - BoxWithConstraints( + val palette = OnboardingTheme.palette + val backgroundColor by animateColorAsState( + targetValue = palette.surfaceBase, + animationSpec = tween(280), + label = "OnboardingBackground" + ) + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.navigationBarColor = backgroundColor.toArgb() + window.statusBarColor = backgroundColor.toArgb() + WindowCompat.getInsetsController(window, view).apply { + isAppearanceLightStatusBars = !darkTheme + isAppearanceLightNavigationBars = !darkTheme + } + } + } + + Box( modifier = Modifier .fillMaxSize() - .background(OnboardingTheme.palette.surfaceBase) + .background(backgroundColor) ) { - val screenHeight = maxHeight - val compactHeight = screenHeight < 760.dp - val horizontalPadding = (maxWidth * 0.055f).coerceIn(20.dp, 36.dp) - - PixelGridBackground( - modifier = Modifier.fillMaxSize(), - spacing = if (compactHeight) 18.dp else 22.dp, - color = OnboardingTheme.palette.textTertiary.copy( - alpha = if (OnboardingTheme.palette.isDark) 0.06f else 0.04f - ) + OnboardingSinglePageScreen( + darkTheme = darkTheme, + reduceMotion = reduceMotion, + onThemeToggle = onThemeToggle, + onComplete = { component.finish() } ) - DiagonalHatchPattern( - modifier = Modifier.fillMaxSize(), - color = OnboardingTheme.palette.lineSoft.copy( - alpha = if (OnboardingTheme.palette.isDark) 0.08f else 0.03f - ) - ) - - Column( - modifier = Modifier - .fillMaxSize() - .statusBarsPadding() - .navigationBarsPadding() - .padding(horizontal = horizontalPadding) - .padding(top = if (compactHeight) 8.dp else 12.dp, bottom = 18.dp) - .widthIn(max = 680.dp) - .align(Alignment.TopCenter), - horizontalAlignment = Alignment.CenterHorizontally - ) { - OnboardingTopBar( - currentPage = state.currentPage, - totalPages = state.totalPages, - onSkip = component::onSkipTapped, - onThemeToggle = onThemeToggle - ) - - Spacer(modifier = Modifier.height(if (compactHeight) 12.dp else 18.dp)) - - AnimatedContent( - targetState = state.currentPage, - modifier = Modifier - .weight(1f) - .fillMaxWidth(), - transitionSpec = { - fadeIn(animationSpec = tween(220)) togetherWith fadeOut(animationSpec = tween(180)) - }, - label = "PageTransition", - contentAlignment = Alignment.TopCenter - ) { pageIndex -> - val page = state.pages[pageIndex] - - FeaturePageView( - page = page, - state = state, - compactHeight = compactHeight, - availableHeight = screenHeight, - onPromptChipTapped = component::onPromptChipTapped, - onPairingToggleTapped = component::onPairingToggleTapped, - onAddQueuedPromptTapped = component::onAddQueuedPromptTapped, - onReasoningLevelChanged = component::onReasoningLevelChanged, - modifier = Modifier.fillMaxSize() - ) - } - - Spacer(modifier = Modifier.height(if (compactHeight) 12.dp else 18.dp)) - - OnboardingBottomNavigation( - currentPage = state.currentPage, - totalPages = state.totalPages, - isLastPage = state.isLastPage, - onNext = component::onNextTapped, - onPrevious = component::onPreviousTapped, - onFinish = component::onFinishTapped, - onPageSelected = component::onPageSelected, - modifier = Modifier.fillMaxWidth() - ) - } } } } diff --git a/app/src/main/java/io/github/bengidev/opencore/onboarding/application/OnboardingComponent.kt b/app/src/main/java/io/github/bengidev/opencore/onboarding/application/OnboardingComponent.kt index c5ba554c..191fc254 100644 --- a/app/src/main/java/io/github/bengidev/opencore/onboarding/application/OnboardingComponent.kt +++ b/app/src/main/java/io/github/bengidev/opencore/onboarding/application/OnboardingComponent.kt @@ -37,25 +37,21 @@ internal class OnboardingComponent( internal fun dispatch(intent: OnboardingIntent) { _state.update { current -> OnboardingReducer.reduce(current, intent) } - when (intent) { - is OnboardingIntent.FinishButtonTapped -> { - scope.launch { - repository.completeOnboarding() - dispatch(OnboardingIntent.CompletionSaved) - onComplete() - } - } - else -> Unit - } } - internal fun onNextTapped() = dispatch(OnboardingIntent.NextButtonTapped) - internal fun onPreviousTapped() = dispatch(OnboardingIntent.PreviousButtonTapped) - internal fun onPageSelected(index: Int) = dispatch(OnboardingIntent.PageSelected(index)) - internal fun onFinishTapped() = dispatch(OnboardingIntent.FinishButtonTapped) - internal fun onSkipTapped() = dispatch(OnboardingIntent.SkipButtonTapped) - internal fun onPromptChipTapped(index: Int) = dispatch(OnboardingIntent.PromptChipTapped(index)) - internal fun onAddQueuedPromptTapped() = dispatch(OnboardingIntent.AddQueuedPromptButtonTapped) - internal fun onReasoningLevelChanged(value: Double) = dispatch(OnboardingIntent.ReasoningLevelChanged(value)) - internal fun onPairingToggleTapped() = dispatch(OnboardingIntent.PairingToggleTapped) + internal fun onFinishTapped() { + scope.launch { finish() } + } + + internal suspend fun finish(): Boolean { + return try { + repository.completeOnboarding() + dispatch(OnboardingIntent.CompletionSaved) + dispatch(OnboardingIntent.FinishRequested) + onComplete() + true + } catch (_: Exception) { + false + } + } } diff --git a/app/src/main/java/io/github/bengidev/opencore/onboarding/application/OnboardingIntent.kt b/app/src/main/java/io/github/bengidev/opencore/onboarding/application/OnboardingIntent.kt index 123e7450..b94484df 100644 --- a/app/src/main/java/io/github/bengidev/opencore/onboarding/application/OnboardingIntent.kt +++ b/app/src/main/java/io/github/bengidev/opencore/onboarding/application/OnboardingIntent.kt @@ -1,20 +1,8 @@ package io.github.bengidev.opencore.onboarding.application -/** - * Command pattern: each user/system action is an explicit intent object. - * Mirrors the Action enum from OpenCore designOnboardingFeature. - */ internal sealed interface OnboardingIntent { data object OnAppear : OnboardingIntent data class CompletionLoaded(val completed: Boolean) : OnboardingIntent - data object NextButtonTapped : OnboardingIntent - data object PreviousButtonTapped : OnboardingIntent - data class PageSelected(val index: Int) : OnboardingIntent - data object SkipButtonTapped : OnboardingIntent - data object FinishButtonTapped : OnboardingIntent + data object FinishRequested : OnboardingIntent data object CompletionSaved : OnboardingIntent - data class PromptChipTapped(val index: Int) : OnboardingIntent - data object AddQueuedPromptButtonTapped : OnboardingIntent - data class ReasoningLevelChanged(val value: Double) : OnboardingIntent - data object PairingToggleTapped : OnboardingIntent } diff --git a/app/src/main/java/io/github/bengidev/opencore/onboarding/application/OnboardingReducer.kt b/app/src/main/java/io/github/bengidev/opencore/onboarding/application/OnboardingReducer.kt index 1faf493e..44415e99 100644 --- a/app/src/main/java/io/github/bengidev/opencore/onboarding/application/OnboardingReducer.kt +++ b/app/src/main/java/io/github/bengidev/opencore/onboarding/application/OnboardingReducer.kt @@ -1,12 +1,5 @@ package io.github.bengidev.opencore.onboarding.application -import io.github.bengidev.opencore.onboarding.domain.OnboardingPromptOption -import io.github.bengidev.opencore.onboarding.domain.OnboardingQueueItem - -/** - * Pure reducer that applies [OnboardingIntent] commands to [OnboardingState]. - * OpenCoreOnboardingFeature reducer body. - */ internal object OnboardingReducer { internal fun reduce(state: OnboardingState, intent: OnboardingIntent): OnboardingState = when (intent) { @@ -15,52 +8,6 @@ internal object OnboardingReducer { is OnboardingIntent.CompletionLoaded -> state.copy(isFinished = intent.completed) - is OnboardingIntent.NextButtonTapped -> { - val nextPage = (state.currentPage + 1).coerceAtMost(state.totalPages - 1) - state.withPageChange(nextPage) - } - - is OnboardingIntent.PreviousButtonTapped -> { - val previousPage = (state.currentPage - 1).coerceAtLeast(0) - state.withPageChange(previousPage) - } - - is OnboardingIntent.PageSelected -> { - val selectedPage = intent.index.coerceIn(0, state.totalPages - 1) - state.withPageChange(selectedPage) - } - - is OnboardingIntent.SkipButtonTapped -> state.copy(currentPage = state.totalPages - 1) - - is OnboardingIntent.FinishButtonTapped -> state.copy(isFinished = true) - - is OnboardingIntent.PromptChipTapped -> { - val safeIndex = intent.index.coerceIn(0, OnboardingPromptOption.samples.size - 1) - state.copy(demoState = state.demoState.copy(selectedPromptIndex = safeIndex)) - } - - is OnboardingIntent.AddQueuedPromptButtonTapped -> { - val newCount = if (state.demoState.queuedPromptCount >= OnboardingQueueItem.samples.size) { - 2 - } else { - state.demoState.queuedPromptCount + 1 - } - state.copy(demoState = state.demoState.copy(queuedPromptCount = newCount)) - } - - is OnboardingIntent.ReasoningLevelChanged -> { - val clamped = intent.value.coerceIn(0.0, 1.0) - state.copy(demoState = state.demoState.copy(reasoningLevel = clamped)) - } - - is OnboardingIntent.PairingToggleTapped -> - state.copy(demoState = state.demoState.copy(pairingConfirmed = !state.demoState.pairingConfirmed)) - } - - private fun OnboardingState.withPageChange(newPage: Int): OnboardingState { - if (newPage == currentPage) return copy(currentPage = newPage) - val pageType = pages.getOrNull(newPage)?.type ?: return copy(currentPage = newPage) - val strategy = PageDemoDefaultsStrategyFactory.forPageType(pageType) - return copy(currentPage = newPage, demoState = strategy.apply(demoState)) + is OnboardingIntent.FinishRequested -> state.copy(isFinished = true) } } diff --git a/app/src/main/java/io/github/bengidev/opencore/onboarding/application/OnboardingState.kt b/app/src/main/java/io/github/bengidev/opencore/onboarding/application/OnboardingState.kt index ddf2b03d..1fb9586c 100644 --- a/app/src/main/java/io/github/bengidev/opencore/onboarding/application/OnboardingState.kt +++ b/app/src/main/java/io/github/bengidev/opencore/onboarding/application/OnboardingState.kt @@ -1,27 +1,5 @@ package io.github.bengidev.opencore.onboarding.application -import io.github.bengidev.opencore.onboarding.domain.OnboardingDemoDefaults -import io.github.bengidev.opencore.onboarding.domain.OnboardingPage -import io.github.bengidev.opencore.onboarding.domain.OnboardingPromptOption -import io.github.bengidev.opencore.onboarding.domain.OnboardingQueueItem - internal data class OnboardingState( - val currentPage: Int = 0, - val isFinished: Boolean = false, - val demoState: DemoState = DemoState() -) { - val pages: List = OnboardingPage.all - val totalPages: Int get() = pages.size - val isLastPage: Boolean get() = currentPage >= totalPages - 1 - val currentPageData: OnboardingPage - get() = pages[currentPage.coerceIn(0, totalPages - 1)] - - internal data class DemoState( - val selectedPromptIndex: Int = OnboardingDemoDefaults.selectedPromptIndex, - val queuedPromptCount: Int = OnboardingDemoDefaults.queuedPromptCount, - val reasoningLevel: Double = OnboardingDemoDefaults.reasoningLevel, - val pairingConfirmed: Boolean = OnboardingDemoDefaults.pairingConfirmed, - val promptOptions: List = OnboardingPromptOption.samples, - val queueItems: List = OnboardingQueueItem.samples - ) -} + val isFinished: Boolean = false +) diff --git a/app/src/main/java/io/github/bengidev/opencore/onboarding/application/PageDemoDefaultsStrategy.kt b/app/src/main/java/io/github/bengidev/opencore/onboarding/application/PageDemoDefaultsStrategy.kt deleted file mode 100644 index bc5c1e03..00000000 --- a/app/src/main/java/io/github/bengidev/opencore/onboarding/application/PageDemoDefaultsStrategy.kt +++ /dev/null @@ -1,37 +0,0 @@ -package io.github.bengidev.opencore.onboarding.application - -import io.github.bengidev.opencore.onboarding.domain.OnboardingPageType -import io.github.bengidev.opencore.onboarding.domain.OnboardingPromptOption -import io.github.bengidev.opencore.onboarding.domain.OnboardingQueueItem - -/** - * Strategy pattern: page-specific demo reset behavior when navigating. - */ -internal interface PageDemoDefaultsStrategy { - fun apply(demoState: OnboardingState.DemoState): OnboardingState.DemoState -} - -internal object NoOpDemoDefaultsStrategy : PageDemoDefaultsStrategy { - override fun apply(demoState: OnboardingState.DemoState): OnboardingState.DemoState = demoState -} - -internal object IdeaStudioDemoDefaultsStrategy : PageDemoDefaultsStrategy { - override fun apply(demoState: OnboardingState.DemoState): OnboardingState.DemoState = - demoState.copy(selectedPromptIndex = 0) -} - -internal object ReasoningControlDemoDefaultsStrategy : PageDemoDefaultsStrategy { - override fun apply(demoState: OnboardingState.DemoState): OnboardingState.DemoState = - demoState.copy(reasoningLevel = 0.62) -} - -/** - * Factory method: selects the strategy for a given page type. - */ -internal object PageDemoDefaultsStrategyFactory { - fun forPageType(type: OnboardingPageType): PageDemoDefaultsStrategy = when (type) { - OnboardingPageType.IdeaStudio -> IdeaStudioDemoDefaultsStrategy - OnboardingPageType.ReasoningControl -> ReasoningControlDemoDefaultsStrategy - else -> NoOpDemoDefaultsStrategy - } -} diff --git a/app/src/main/java/io/github/bengidev/opencore/onboarding/domain/OnboardingDemoDefaults.kt b/app/src/main/java/io/github/bengidev/opencore/onboarding/domain/OnboardingDemoDefaults.kt deleted file mode 100644 index 67c25a76..00000000 --- a/app/src/main/java/io/github/bengidev/opencore/onboarding/domain/OnboardingDemoDefaults.kt +++ /dev/null @@ -1,8 +0,0 @@ -package io.github.bengidev.opencore.onboarding.domain - -internal object OnboardingDemoDefaults { - const val selectedPromptIndex: Int = 0 - const val reasoningLevel: Double = 0.62 - const val queuedPromptCount: Int = 2 - const val pairingConfirmed: Boolean = true -} diff --git a/app/src/main/java/io/github/bengidev/opencore/onboarding/domain/OnboardingFeature.kt b/app/src/main/java/io/github/bengidev/opencore/onboarding/domain/OnboardingFeature.kt new file mode 100644 index 00000000..6281fc17 --- /dev/null +++ b/app/src/main/java/io/github/bengidev/opencore/onboarding/domain/OnboardingFeature.kt @@ -0,0 +1,49 @@ +package io.github.bengidev.opencore.onboarding.domain + +import androidx.annotation.DrawableRes +import io.github.bengidev.opencore.R + +internal data class OnboardingFeature( + val id: String, + val title: String, + val subtitle: String, + val description: String, + @param:DrawableRes val imageRes: Int +) { + companion object { + val catalog: List = listOf( + OnboardingFeature( + id = "neural_core", + title = "Intelligent Neural Core", + subtitle = "On-device contextual reasoning with zero external latency.", + description = "Understands your workspace context locally — no cloud round trips, no network lag. " + + "Models run on device so answers stay private and feel instant.", + imageRes = R.drawable.onboarding_feature_neural_core + ), + OnboardingFeature( + id = "spatial_canvas", + title = "Dynamic Spatial Canvas", + subtitle = "Multi-dimensional organization for fluid workspace mapping.", + description = "Arrange notes, files, and threads in a spatial layout that mirrors how you think. " + + "Pan, cluster, and refocus without losing track of where anything lives.", + imageRes = R.drawable.onboarding_feature_spatial_canvas + ), + OnboardingFeature( + id = "workflows", + title = "Autonomous Workflows", + subtitle = "Self-healing pipelines that automate cross-tool tasks.", + description = "Chain actions across apps with routines that recover on their own. " + + "Set triggers once and let background pipelines handle the repetitive work.", + imageRes = R.drawable.onboarding_feature_workflows + ), + OnboardingFeature( + id = "vault", + title = "Encrypted Edge Vault", + subtitle = "Zero-knowledge security anchored to device hardware.", + description = "Keys are sealed in hardware-backed storage with zero-knowledge encryption. " + + "Your vault stays on-device — only you hold the keys.", + imageRes = R.drawable.onboarding_feature_vault + ) + ) + } +} diff --git a/app/src/main/java/io/github/bengidev/opencore/onboarding/domain/OnboardingFeatureHighlight.kt b/app/src/main/java/io/github/bengidev/opencore/onboarding/domain/OnboardingFeatureHighlight.kt deleted file mode 100644 index b958249f..00000000 --- a/app/src/main/java/io/github/bengidev/opencore/onboarding/domain/OnboardingFeatureHighlight.kt +++ /dev/null @@ -1,7 +0,0 @@ -package io.github.bengidev.opencore.onboarding.domain - -internal data class OnboardingFeatureHighlight( - val title: String, - val detail: String, - val iconRes: Int -) diff --git a/app/src/main/java/io/github/bengidev/opencore/onboarding/domain/OnboardingPage.kt b/app/src/main/java/io/github/bengidev/opencore/onboarding/domain/OnboardingPage.kt deleted file mode 100644 index a578a552..00000000 --- a/app/src/main/java/io/github/bengidev/opencore/onboarding/domain/OnboardingPage.kt +++ /dev/null @@ -1,145 +0,0 @@ -package io.github.bengidev.opencore.onboarding.domain - -internal data class OnboardingPage( - val type: OnboardingPageType, - val indexLabel: String, - val eyebrow: String, - val headline: String, - val body: String, - val metric: String, - val command: String, - val shaderIntensity: Double, - val highlights: List -) { - val id: String get() = type.name - - companion object { - val all: List = listOf( - OnboardingPage( - type = OnboardingPageType.EncryptedPairing, - indexLabel = "SEC-01", - eyebrow = "Encrypted pairing", - headline = "End-to-end encrypted pairing and chats", - body = "Pair trusted devices, keep local workspace context private, and open AI chats without leaking the conversation boundary.", - metric = "E2E CHANNEL", - command = "pair --device workspace --mode sealed", - shaderIntensity = 0.78, - highlights = listOf( - OnboardingFeatureHighlight( - title = "Local memory", - detail = "Persists offline", - iconRes = android.R.drawable.ic_menu_save - ), - OnboardingFeatureHighlight( - title = "Secure session", - detail = "Rotates keys", - iconRes = android.R.drawable.ic_lock_lock - ) - ) - ), - OnboardingPage( - type = OnboardingPageType.IdeaStudio, - indexLabel = "AI-02", - eyebrow = "Model workspace", - headline = "Ask questions, write, and explore ideas with AI models", - body = "OpenCore turns prompts into a focused working surface for drafting, refactoring, research, and interface decisions.", - metric = "MODEL STUDIO", - command = "ask --model adaptive --context project", - shaderIntensity = 0.55, - highlights = listOf( - OnboardingFeatureHighlight( - title = "Design canvas", - detail = "Native cards", - iconRes = android.R.drawable.ic_menu_gallery - ), - OnboardingFeatureHighlight( - title = "AI assistance", - detail = "Structured sessions", - iconRes = android.R.drawable.ic_menu_edit - ) - ) - ), - OnboardingPage( - type = OnboardingPageType.PromptQueue, - indexLabel = "RUN-03", - eyebrow = "Queue control", - headline = "Queue follow-up prompts while a turn is still running", - body = "Keep momentum by lining up the next question, test request, or implementation step before the current model turn finishes.", - metric = "LIVE QUEUE", - command = "queue append --while-running follow-up", - shaderIntensity = 0.66, - highlights = listOf( - OnboardingFeatureHighlight( - title = "State engine", - detail = "Explicit actions", - iconRes = android.R.drawable.ic_menu_sort_by_size - ), - OnboardingFeatureHighlight( - title = "Run steering", - detail = "Visible follow-ups", - iconRes = android.R.drawable.ic_media_play - ) - ) - ), - OnboardingPage( - type = OnboardingPageType.ReasoningControl, - indexLabel = "THK-04", - eyebrow = "Reasoning dial", - headline = "Tune how much thinking the AI uses", - body = "Choose faster answers, balanced planning, or deeper reasoning before the AI model commits compute to the task.", - metric = "THINK BUDGET", - command = "model set reasoning --level balanced", - shaderIntensity = 0.82, - highlights = listOf( - OnboardingFeatureHighlight( - title = "Model controls", - detail = "Adjust thinking", - iconRes = android.R.drawable.ic_menu_preferences - ), - OnboardingFeatureHighlight( - title = "Human steering", - detail = "User-set compute", - iconRes = android.R.drawable.ic_menu_myplaces - ) - ) - ), - OnboardingPage( - type = OnboardingPageType.WorkspaceReady, - indexLabel = "RDY-05", - eyebrow = "Workspace ready", - headline = "OpenCore", - body = "Your AI-native command center. Deploy specialized agents to handle code, review, test, and ship — all within your existing workflow without context switching.", - metric = "WORKSPACE", - command = "opencore enter --mode production", - shaderIntensity = 0.45, - highlights = listOf( - OnboardingFeatureHighlight( - title = "Agents", - detail = "Specialized runners", - iconRes = android.R.drawable.ic_menu_manage - ), - OnboardingFeatureHighlight( - title = "Prompts", - detail = "Structured input", - iconRes = android.R.drawable.ic_menu_send - ), - OnboardingFeatureHighlight( - title = "Models", - detail = "Adaptive compute", - iconRes = android.R.drawable.ic_menu_view - ), - OnboardingFeatureHighlight( - title = "Review", - detail = "Iterative feedback", - iconRes = android.R.drawable.ic_menu_revert - ), - OnboardingFeatureHighlight( - title = "Ship", - detail = "Deploy ready", - iconRes = android.R.drawable.ic_menu_upload - ) - ) - ) - ) - } -} diff --git a/app/src/main/java/io/github/bengidev/opencore/onboarding/domain/OnboardingPageType.kt b/app/src/main/java/io/github/bengidev/opencore/onboarding/domain/OnboardingPageType.kt deleted file mode 100644 index 5a5954a0..00000000 --- a/app/src/main/java/io/github/bengidev/opencore/onboarding/domain/OnboardingPageType.kt +++ /dev/null @@ -1,13 +0,0 @@ -package io.github.bengidev.opencore.onboarding.domain - -internal enum class OnboardingPageType { - EncryptedPairing, - IdeaStudio, - PromptQueue, - ReasoningControl, - WorkspaceReady; - - companion object { - val allCases: List = entries - } -} diff --git a/app/src/main/java/io/github/bengidev/opencore/onboarding/domain/OnboardingPromptOption.kt b/app/src/main/java/io/github/bengidev/opencore/onboarding/domain/OnboardingPromptOption.kt deleted file mode 100644 index 5db8d718..00000000 --- a/app/src/main/java/io/github/bengidev/opencore/onboarding/domain/OnboardingPromptOption.kt +++ /dev/null @@ -1,25 +0,0 @@ -package io.github.bengidev.opencore.onboarding.domain - -internal data class OnboardingPromptOption( - val label: String, - val prompt: String -) { - val id: String get() = prompt - - companion object { - val samples: List = listOf( - OnboardingPromptOption( - label = "ASK", - prompt = "How should I structure the memory model for this workflow?" - ), - OnboardingPromptOption( - label = "WRITE", - prompt = "Draft a concise interface view for the secure pairing step." - ), - OnboardingPromptOption( - label = "EXPLORE", - prompt = "Compare state actions for queued prompts and reasoning controls." - ) - ) - } -} diff --git a/app/src/main/java/io/github/bengidev/opencore/onboarding/domain/OnboardingQueueItem.kt b/app/src/main/java/io/github/bengidev/opencore/onboarding/domain/OnboardingQueueItem.kt deleted file mode 100644 index 9d2fe740..00000000 --- a/app/src/main/java/io/github/bengidev/opencore/onboarding/domain/OnboardingQueueItem.kt +++ /dev/null @@ -1,41 +0,0 @@ -package io.github.bengidev.opencore.onboarding.domain - -internal data class OnboardingQueueItem( - val title: String, - val detail: String, - val status: Status -) { - val id: String get() = title - - internal enum class Status { - RUNNING, - NEXT, - QUEUED, - READY - } - - companion object { - val samples: List = listOf( - OnboardingQueueItem( - title = "Map onboarding state", - detail = "Engine already owns current page", - status = Status.RUNNING - ), - OnboardingQueueItem( - title = "Generate interface cards", - detail = "No vertical scroll, compact content", - status = Status.NEXT - ), - OnboardingQueueItem( - title = "Persist completion", - detail = "Storage writes local progress", - status = Status.QUEUED - ), - OnboardingQueueItem( - title = "Review model budget", - detail = "Reasoning slider updates the run", - status = Status.READY - ) - ) - } -} diff --git a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/FeaturePageView.kt b/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/FeaturePageView.kt deleted file mode 100644 index f647df5e..00000000 --- a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/FeaturePageView.kt +++ /dev/null @@ -1,361 +0,0 @@ -package io.github.bengidev.opencore.onboarding.presenter - -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -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.interaction.collectIsPressedAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -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.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -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.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.scale -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import io.github.bengidev.opencore.onboarding.application.OnboardingState -import io.github.bengidev.opencore.onboarding.domain.OnboardingPage -import io.github.bengidev.opencore.onboarding.domain.OnboardingPageType -import io.github.bengidev.opencore.onboarding.domain.OnboardingQueueItem -import io.github.bengidev.opencore.onboarding.presenter.components.Badge -import io.github.bengidev.opencore.onboarding.presenter.components.CardChrome -import io.github.bengidev.opencore.onboarding.presenter.components.DiagonalHatchPattern -import io.github.bengidev.opencore.onboarding.presenter.components.HighlightFooter -import io.github.bengidev.opencore.onboarding.presenter.components.IndexLabelChip -import io.github.bengidev.opencore.onboarding.presenter.components.PixelGridBackground -import io.github.bengidev.opencore.ui.components.ScaleToFitText -import io.github.bengidev.opencore.onboarding.presenter.components.TerminalHeader -import io.github.bengidev.opencore.onboarding.presenter.components.iconForPageType -import io.github.bengidev.opencore.onboarding.theme.OnboardingTheme -import kotlinx.coroutines.delay - -/** Page scaffold — iOS OnboardingFeaturePageView. */ -@Composable -internal fun FeaturePageView( - page: OnboardingPage, - state: OnboardingState, - compactHeight: Boolean, - availableHeight: Dp, - onPromptChipTapped: (Int) -> Unit, - onPairingToggleTapped: () -> Unit, - onAddQueuedPromptTapped: () -> Unit, - onReasoningLevelChanged: (Double) -> Unit, - modifier: Modifier = Modifier -) { - val palette = OnboardingTheme.palette - - var appeared by remember(page.id) { mutableStateOf(false) } - - val alpha by animateFloatAsState( - targetValue = if (appeared) 1f else 0f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessLow - ), - label = "feature_alpha" - ) - - val slideOffset by animateFloatAsState( - targetValue = if (appeared) 0f else 12f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessLow - ), - label = "feature_slide" - ) - - LaunchedEffect(page.id) { - appeared = false - delay(70) - appeared = true - } - - if (page.type == OnboardingPageType.WorkspaceReady) { - Box( - modifier = modifier - .fillMaxSize() - .graphicsLayer { - this.alpha = alpha - translationY = slideOffset - } - ) { - PageVisualFactory( - page = page, - state = state, - appeared = appeared, - onPromptChipTapped = onPromptChipTapped, - onPairingToggleTapped = onPairingToggleTapped, - onReasoningLevelChanged = onReasoningLevelChanged, - modifier = Modifier.fillMaxSize() - ) - } - return - } - - val visualHeight = remember(compactHeight, availableHeight) { - val ratio = if (compactHeight) 0.39f else 0.43f - val minH = if (compactHeight) 270.dp else 326.dp - val maxH = 390.dp - (availableHeight * ratio).coerceIn(minH, maxH) - } - - Column( - modifier = modifier - .fillMaxWidth() - .graphicsLayer { - this.alpha = alpha - translationY = slideOffset - }, - verticalArrangement = Arrangement.spacedBy(13.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Badge(text = page.eyebrow, icon = iconForPageType(page.type)) - Spacer(modifier = Modifier.weight(1f)) - IndexLabelChip(label = page.indexLabel) - } - - ScaleToFitText( - text = page.headline, - style = OnboardingTheme.typography.displayLg, - color = palette.textPrimary, - maxLines = 2, - minFontSize = 32.sp, - modifier = Modifier.fillMaxWidth() - ) - - ScaleToFitText( - text = page.body, - style = OnboardingTheme.typography.bodyMd.copy(fontSize = 13.5.sp, lineHeight = 18.sp), - color = palette.textSecondary, - maxLines = 3, - minFontSize = 11.sp, - modifier = Modifier.fillMaxWidth() - ) - - CardChrome( - modifier = Modifier - .fillMaxWidth() - .height(visualHeight) - ) { - Box(modifier = Modifier.fillMaxSize()) { - PixelGridBackground( - modifier = Modifier.fillMaxSize(), - spacing = 15.dp, - color = palette.textTertiary.copy(alpha = if (palette.isDark) 0.06f else 0.04f) - ) - DiagonalHatchPattern( - modifier = Modifier.fillMaxSize(), - color = palette.lineSoft.copy(alpha = if (palette.isDark) 0.08f else 0.03f) - ) - - if (page.type == OnboardingPageType.PromptQueue) { - PromptQueueCardContent( - page = page, - state = state, - appeared = appeared, - onAddQueuedPromptTapped = onAddQueuedPromptTapped, - onPromptChipTapped = onPromptChipTapped, - onPairingToggleTapped = onPairingToggleTapped, - onReasoningLevelChanged = onReasoningLevelChanged - ) - } else { - Column( - modifier = Modifier - .fillMaxSize() - .padding(14.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - TerminalHeader(page = page) - PageVisualFactory( - page = page, - state = state, - appeared = appeared, - onPromptChipTapped = onPromptChipTapped, - onPairingToggleTapped = onPairingToggleTapped, - onReasoningLevelChanged = onReasoningLevelChanged, - modifier = Modifier - .weight(1f) - .fillMaxWidth() - ) - if (page.highlights.isNotEmpty()) { - HighlightFooter(highlights = page.highlights) - } - } - } - } - } - } -} - -@Composable -private fun PromptQueueCardContent( - page: OnboardingPage, - state: OnboardingState, - appeared: Boolean, - onAddQueuedPromptTapped: () -> Unit, - onPromptChipTapped: (Int) -> Unit, - onPairingToggleTapped: () -> Unit, - onReasoningLevelChanged: (Double) -> Unit -) { - val queueFull = state.demoState.queuedPromptCount >= OnboardingQueueItem.samples.size - val queueScrollState = rememberScrollState() - - LaunchedEffect(state.demoState.queuedPromptCount) { - delay(80) - val start = queueScrollState.value - val end = queueScrollState.maxValue - if (end <= start) return@LaunchedEffect - val steps = 12 - repeat(steps) { step -> - val fraction = FastOutSlowInEasing.transform((step + 1) / steps.toFloat()) - queueScrollState.scrollTo(start + ((end - start) * fraction).toInt()) - delay(28) - } - } - - Column(modifier = Modifier.fillMaxSize()) { - TerminalHeader( - page = page, - modifier = Modifier - .padding(horizontal = 14.dp) - .padding(top = 14.dp, bottom = 8.dp) - ) - - Box( - modifier = Modifier - .weight(1f) - .fillMaxWidth() - .padding(horizontal = 14.dp) - .verticalScroll(queueScrollState), - contentAlignment = Alignment.Center - ) { - PageVisualFactory( - page = page, - state = state, - appeared = appeared, - onPromptChipTapped = onPromptChipTapped, - onPairingToggleTapped = onPairingToggleTapped, - onReasoningLevelChanged = onReasoningLevelChanged, - modifier = Modifier.fillMaxWidth() - ) - } - - AddFollowUpQueueButton( - queueFull = queueFull, - onClick = onAddQueuedPromptTapped, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 14.dp) - ) - - if (page.highlights.isNotEmpty()) { - HighlightFooter( - highlights = page.highlights, - modifier = Modifier.padding(horizontal = 14.dp, vertical = 14.dp) - ) - } - } -} - -@Composable -private fun AddFollowUpQueueButton( - queueFull: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier -) { - val palette = OnboardingTheme.palette - val interactionSource = remember { MutableInteractionSource() } - val pressed by interactionSource.collectIsPressedAsState() - - val scale by animateFloatAsState( - targetValue = if (pressed) 0.968f else 1f, - animationSpec = spring(dampingRatio = 0.62f, stiffness = 340f), - label = "add_followup_scale" - ) - val backgroundAlpha by animateFloatAsState( - targetValue = if (pressed) 0.18f else 0.11f, - animationSpec = tween(durationMillis = 180), - label = "add_followup_bg" - ) - val borderAlpha by animateFloatAsState( - targetValue = if (pressed) 0.42f else 0.28f, - animationSpec = tween(durationMillis = 180), - label = "add_followup_border" - ) - val iconRotation by animateFloatAsState( - targetValue = if (pressed) 90f else 0f, - animationSpec = spring(dampingRatio = 0.72f, stiffness = 280f), - label = "add_followup_icon_rotation" - ) - - Row( - modifier = modifier - .scale(scale) - .clip(RoundedCornerShape(6.dp)) - .background(palette.accentPrimary.copy(alpha = backgroundAlpha)) - .border( - width = 1.dp, - color = palette.accentPrimary.copy(alpha = borderAlpha), - shape = RoundedCornerShape(6.dp) - ) - .clickable( - interactionSource = interactionSource, - indication = null, - onClick = onClick - ) - .padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - imageVector = Icons.Filled.Add, - contentDescription = null, - tint = palette.accentPrimary, - modifier = Modifier - .height(14.dp) - .graphicsLayer { rotationZ = iconRotation } - ) - Text( - text = if (queueFull) "RESET QUEUE" else "ADD FOLLOW-UP", - style = OnboardingTheme.typography.monoSm, - color = palette.accentPrimary, - modifier = Modifier.weight(1f) - ) - Text( - text = "⌘↩", - style = OnboardingTheme.typography.monoXs, - color = palette.textTertiary - ) - } -} diff --git a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/HeroCubeLayout.kt b/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/HeroCubeLayout.kt new file mode 100644 index 00000000..595c78f7 --- /dev/null +++ b/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/HeroCubeLayout.kt @@ -0,0 +1,98 @@ +package io.github.bengidev.opencore.onboarding.presenter + +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.layout.layout +import androidx.compose.ui.unit.Constraints + +internal data class HeroCubeLayout( + val size: Float, + val center: Offset +) + +internal fun Modifier.heroCubeOverlay(layout: HeroCubeLayout): Modifier = this.layout { measurable, constraints -> + val sizePx = layout.size.coerceAtLeast(1f) + val measurePx = sizePx.toInt().coerceAtLeast(1) + val sizeScale = sizePx / measurePx + val placeable = measurable.measure(Constraints.fixed(measurePx, measurePx)) + val left = layout.center.x - layout.size / 2f + val top = layout.center.y - layout.size / 2f + layout(constraints.maxWidth, constraints.maxHeight) { + placeable.placeWithLayer( + x = left.toInt(), + y = top.toInt(), + layerBlock = { + scaleX = sizeScale + scaleY = sizeScale + transformOrigin = TransformOrigin(0f, 0f) + translationX = left - left.toInt() + translationY = top - top.toInt() + } + ) + } +} + +internal object HeroCubeLayoutMath { + const val HERO_ROTATION_END_TRANSITION = 0.38f + const val HERO_MORPH_START_TRANSITION = 0.54f + + fun heroRotationProgress(transition: Float): Float { + val raw = (transition / HERO_ROTATION_END_TRANSITION).coerceIn(0f, 1f) + return 1f - (1f - raw) * (1f - raw) + } + + fun heroMorphProgress(transition: Float): Float { + val span = (1f - HERO_MORPH_START_TRANSITION).coerceAtLeast(0.001f) + val raw = ((transition - HERO_MORPH_START_TRANSITION) / span).coerceIn(0f, 1f) + return 1f - (1f - raw) * (1f - raw) * (1f - raw) + } + + fun layoutForTransition( + transition: Float, + width: Float, + height: Float, + largeSize: Float, + smallSize: Float, + headerTopPadding: Float, + headerHorizontalPadding: Float, + safeTop: Float = 0f + ): HeroCubeLayout = layoutForMorph( + morph = heroMorphProgress(transition), + width = width, + height = height, + largeSize = largeSize, + smallSize = smallSize, + headerTopPadding = headerTopPadding, + headerHorizontalPadding = headerHorizontalPadding, + safeTop = safeTop + ) + + fun layoutForMorph( + morph: Float, + width: Float, + height: Float, + largeSize: Float, + smallSize: Float, + headerTopPadding: Float, + headerHorizontalPadding: Float, + safeTop: Float + ): HeroCubeLayout { + val clampedMorph = morph.coerceIn(0f, 1f) + val cubeSize = largeSize + (smallSize - largeSize) * clampedMorph + + val largeCenter = Offset(width * 0.5f, height * 0.5f) + val smallCenter = Offset( + headerHorizontalPadding + smallSize * 0.5f, + safeTop + headerTopPadding + smallSize * 0.5f + ) + + return HeroCubeLayout( + size = cubeSize, + center = Offset( + largeCenter.x + (smallCenter.x - largeCenter.x) * clampedMorph, + largeCenter.y + (smallCenter.y - largeCenter.y) * clampedMorph + ) + ) + } +} diff --git a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/OnboardingSinglePageScreen.kt b/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/OnboardingSinglePageScreen.kt new file mode 100644 index 00000000..75d1fc23 --- /dev/null +++ b/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/OnboardingSinglePageScreen.kt @@ -0,0 +1,260 @@ +package io.github.bengidev.opencore.onboarding.presenter + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.material3.Text +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.text.PlatformTextStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.view.HapticFeedbackConstantsCompat +import androidx.core.view.ViewCompat +import io.github.bengidev.opencore.onboarding.presenter.carousel.FeatureCardCarousel +import io.github.bengidev.opencore.onboarding.presenter.components.SwipeToStartView +import io.github.bengidev.opencore.onboarding.presenter.cube.OnboardingCubeView +import io.github.bengidev.opencore.onboarding.theme.OnboardingTheme +import io.github.bengidev.opencore.ui.components.ThemeToggleButton +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private val HeroLargeSize = 220.dp +private val HeroSmallSize = 36.dp +private val HeaderCubeTextGap = 14.dp +private val HeaderTopPadding = 18.dp +private val HeaderHorizontalPadding = 24.dp +private val FooterBottomPadding = 2.dp +private const val SwipeSectionDelayMs = 620L +private const val HeroShowoffDelayMs = 1750L +private const val HeroTransitionDurationMs = 1020 +// Matches iOS `.smooth(duration: 1.02, extraBounce: 0)`. +private val HeroTransitionEasing = CubicBezierEasing(0.32f, 0.72f, 0f, 1f) + +@Composable +internal fun OnboardingSinglePageScreen( + darkTheme: Boolean, + reduceMotion: Boolean, + onThemeToggle: () -> Unit, + onComplete: suspend () -> Boolean, + modifier: Modifier = Modifier +) { + val palette = OnboardingTheme.palette + val backgroundColor by animateColorAsState( + targetValue = palette.surfaceBase, + animationSpec = tween(280), + label = "OnboardingSurface" + ) + val density = LocalDensity.current + val view = LocalView.current + + var cubeAppeared by remember { mutableStateOf(false) } + val heroTransitionAnim = remember { Animatable(0f) } + var isTransformed by remember { mutableStateOf(false) } + var showCarousel by remember { mutableStateOf(false) } + var carouselRevealed by remember { mutableStateOf(false) } + var swipeCompleted by remember { mutableStateOf(false) } + + val heroTransitionAnimated = heroTransitionAnim.value + + val headerAlpha by animateFloatAsState( + targetValue = if (isTransformed) 1f else 0f, + animationSpec = if (reduceMotion) tween(250) else tween(500, delayMillis = 120), + label = "HeaderAlpha" + ) + + val swipeAlpha by animateFloatAsState( + targetValue = if (isTransformed) 1f else 0f, + animationSpec = if (reduceMotion) { + tween(200) + } else { + tween( + durationMillis = 600, + delayMillis = SwipeSectionDelayMs.toInt(), + easing = CubicBezierEasing(0.32f, 0.72f, 0f, 1f) + ) + }, + label = "SwipeAlpha" + ) + + val carouselAlpha by animateFloatAsState( + targetValue = if (carouselRevealed) 1f else 0f, + animationSpec = if (reduceMotion) { + tween(200) + } else { + spring(dampingRatio = 0.78f, stiffness = 320f) + }, + label = "CarouselAlpha" + ) + + LaunchedEffect(reduceMotion) { + cubeAppeared = true + if (!reduceMotion) delay(HeroShowoffDelayMs) + isTransformed = true + showCarousel = true + + launch { + if (reduceMotion) { + heroTransitionAnim.snapTo(1f) + } else { + heroTransitionAnim.animateTo( + targetValue = 1f, + animationSpec = tween( + durationMillis = HeroTransitionDurationMs, + easing = HeroTransitionEasing + ) + ) + } + } + + if (!reduceMotion) delay(SwipeSectionDelayMs) + carouselRevealed = true + } + + BoxWithConstraints( + modifier = modifier + .fillMaxSize() + .background(backgroundColor) + .statusBarsPadding() + .navigationBarsPadding() + ) { + val widthPx = constraints.maxWidth.toFloat() + val heightPx = constraints.maxHeight.toFloat() + val largeSizePx = with(density) { HeroLargeSize.toPx() } + val smallSizePx = with(density) { HeroSmallSize.toPx() } + val headerTopPx = with(density) { HeaderTopPadding.toPx() } + val headerHorizontalPx = with(density) { HeaderHorizontalPadding.toPx() } + + val heroLayout = HeroCubeLayoutMath.layoutForTransition( + transition = heroTransitionAnimated, + width = widthPx, + height = heightPx, + largeSize = largeSizePx, + smallSize = smallSizePx, + headerTopPadding = headerTopPx, + headerHorizontalPadding = headerHorizontalPx + ) + + val rotationProgress = HeroCubeLayoutMath.heroRotationProgress(heroTransitionAnimated) + val morphPaused = showCarousel && carouselRevealed + + Column(modifier = Modifier.fillMaxSize()) { + if (isTransformed) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = HeaderTopPadding, bottom = 2.dp) + .padding(horizontal = HeaderHorizontalPadding) + .graphicsLayer { + alpha = headerAlpha + translationX = if (isTransformed) 0f else -14f + }, + verticalAlignment = Alignment.CenterVertically + ) { + Spacer(modifier = Modifier.size(HeroSmallSize)) + Text( + text = "OPENCORE", + modifier = Modifier.padding(start = HeaderCubeTextGap), + color = palette.textPrimary, + style = TextStyle( + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + letterSpacing = 0.04.sp, + platformStyle = PlatformTextStyle(includeFontPadding = false) + ) + ) + } + } + + if (isTransformed && showCarousel) { + FeatureCardCarousel( + isActive = true, + reduceMotion = reduceMotion, + contentRevealed = carouselRevealed, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 14.dp) + .graphicsLayer { alpha = carouselAlpha } + ) + } else { + Spacer(modifier = Modifier.weight(1f)) + } + + if (isTransformed) { + SwipeToStartView( + isUnlocked = swipeCompleted, + reduceMotion = reduceMotion, + onComplete = { + val succeeded = onComplete() + if (succeeded) { + swipeCompleted = true + ViewCompat.performHapticFeedback( + view, + HapticFeedbackConstantsCompat.CONFIRM + ) + } + succeeded + }, + modifier = Modifier + .padding(horizontal = 20.dp) + .padding(bottom = FooterBottomPadding) + .graphicsLayer { + alpha = swipeAlpha + translationY = if (isTransformed) 0f else 28f + } + ) + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = HeaderTopPadding, end = HeaderHorizontalPadding), + contentAlignment = Alignment.TopEnd + ) { + ThemeToggleButton( + palette = palette, + isDark = darkTheme, + onClick = onThemeToggle + ) + } + + OnboardingCubeView( + appeared = cubeAppeared, + inkColor = palette.textPrimary, + morphPaused = morphPaused, + rotationProgress = rotationProgress, + reduceMotion = reduceMotion, + modifier = Modifier.heroCubeOverlay(heroLayout) + ) + } +} diff --git a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/PageVisualFactory.kt b/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/PageVisualFactory.kt deleted file mode 100644 index d9ee0350..00000000 --- a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/PageVisualFactory.kt +++ /dev/null @@ -1,72 +0,0 @@ -package io.github.bengidev.opencore.onboarding.presenter - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import io.github.bengidev.opencore.onboarding.application.OnboardingState -import io.github.bengidev.opencore.onboarding.domain.OnboardingPage -import io.github.bengidev.opencore.onboarding.domain.OnboardingPageType -import io.github.bengidev.opencore.onboarding.presenter.visuals.EncryptedPairingVisualView -import io.github.bengidev.opencore.onboarding.presenter.visuals.IdeaStudioVisualView -import io.github.bengidev.opencore.onboarding.presenter.visuals.PromptQueueVisualView -import io.github.bengidev.opencore.onboarding.presenter.visuals.ReasoningControlVisualView -import io.github.bengidev.opencore.onboarding.presenter.visuals.WorkspaceReadyVisualView - -/** - * Factory dispatcher that routes page types to their corresponding visual demo views. - */ -@Composable -internal fun PageVisualFactory( - page: OnboardingPage, - state: OnboardingState, - appeared: Boolean, - onPromptChipTapped: (Int) -> Unit, - onPairingToggleTapped: () -> Unit, - onReasoningLevelChanged: (Double) -> Unit, - modifier: Modifier = Modifier -) { - when (page.type) { - OnboardingPageType.EncryptedPairing -> { - EncryptedPairingVisualView( - pairingConfirmed = state.demoState.pairingConfirmed, - onTogglePairing = onPairingToggleTapped, - onActionButtonClick = onPairingToggleTapped, - appeared = appeared, - modifier = modifier - ) - } - - OnboardingPageType.IdeaStudio -> { - IdeaStudioVisualView( - selectedPromptIndex = state.demoState.selectedPromptIndex, - onPromptSelected = onPromptChipTapped, - appeared = appeared, - modifier = modifier - ) - } - - OnboardingPageType.PromptQueue -> { - PromptQueueVisualView( - queuedPromptCount = state.demoState.queuedPromptCount, - appeared = appeared, - modifier = modifier - ) - } - - OnboardingPageType.ReasoningControl -> { - ReasoningControlVisualView( - reasoningLevel = state.demoState.reasoningLevel.toFloat(), - onReasoningLevelChanged = { onReasoningLevelChanged(it.toDouble()) }, - appeared = appeared, - modifier = modifier - ) - } - - OnboardingPageType.WorkspaceReady -> { - WorkspaceReadyVisualView( - page = page, - appeared = appeared, - modifier = modifier - ) - } - } -} diff --git a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/carousel/FeatureCardCarousel.kt b/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/carousel/FeatureCardCarousel.kt new file mode 100644 index 00000000..b746b5b1 --- /dev/null +++ b/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/carousel/FeatureCardCarousel.kt @@ -0,0 +1,939 @@ +package io.github.bengidev.opencore.onboarding.presenter.carousel + +import android.os.Build +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.draw.scale +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.lerp as colorLerp +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.input.pointer.util.VelocityTracker +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.lerp +import androidx.compose.ui.zIndex +import io.github.bengidev.opencore.onboarding.domain.OnboardingFeature +import io.github.bengidev.opencore.onboarding.theme.OnboardingTheme +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.filter +import kotlin.math.abs +import kotlin.math.hypot +import kotlin.math.pow +import kotlin.math.roundToInt +import kotlin.math.sin + +private const val CardWidthRatio = 0.74f +private const val CardHeightRatio = 0.84f +private const val CardAspectRatio = 1.48f +private const val CardInterCardGapDp = 14f +private const val HoldDurationMs = 4_400L +private const val InitialAdvanceDelayMs = 680L +private const val RevealHoldDurationMs = 380L +private const val PressStartDelayMs = 64L +private const val CarouselSettleDelayMs = 920L +private const val PressMaximumDistanceDp = 14f +private const val CarouselDragMinimumDistanceDp = 8f +private val CarouselSpring = spring(dampingRatio = 0.9f, stiffness = 340f) +private val RevealSpring = spring(dampingRatio = 0.86f, stiffness = 203f) +private val RevealDismissSpring = spring(dampingRatio = 0.88f, stiffness = 502f) +private val AmbientBobEaseIn = CubicBezierEasing(0.4f, 0f, 0.2f, 1f) +private val AmbientBobEaseOut = CubicBezierEasing(0.4f, 0f, 1f, 1f) +private const val AmbientBobRampInMs = 594 +private const val AmbientBobRampOutMs = 396 + +@Composable +internal fun FeatureCardCarousel( + isActive: Boolean, + reduceMotion: Boolean, + contentRevealed: Boolean = true, + modifier: Modifier = Modifier, + initialAdvanceDelayMs: Long = InitialAdvanceDelayMs +) { + val features = remember { OnboardingFeature.catalog } + var scrollTarget by remember { mutableFloatStateOf(0f) } + var dragScroll by remember { mutableFloatStateOf(0f) } + var releaseFrom by remember { mutableFloatStateOf(Float.NaN) } + var isUserDragging by remember { mutableStateOf(false) } + var dragOriginScrollIndex by remember { mutableFloatStateOf(0f) } + var isImageRevealed by remember { mutableStateOf(false) } + var isUserPressing by remember { mutableStateOf(false) } + var isScrollAnimating by remember { mutableStateOf(false) } + var isCarouselSettling by remember { mutableStateOf(false) } + var pendingCarouselSettle by remember { mutableStateOf(false) } + val focusedPressCharge = remember { Animatable(0f) } + val scope = rememberCoroutineScope() + val density = LocalDensity.current + val haptics = LocalHapticFeedback.current + val scrollOffset = remember { Animatable(0f) } + + LaunchedEffect(scrollTarget, isUserDragging) { + if (isUserDragging) { + isCarouselSettling = false + return@LaunchedEffect + } + if (!releaseFrom.isNaN()) { + scrollOffset.snapTo(releaseFrom) + releaseFrom = Float.NaN + } + isScrollAnimating = true + scrollOffset.animateTo(scrollTarget, CarouselSpring) + val normalized = normalizeScrollIndex(scrollTarget, features.size) + if (abs(normalized - scrollTarget) > 0.001f) { + scrollTarget = normalized + scrollOffset.snapTo(normalized) + } + if (pendingCarouselSettle) { + isCarouselSettling = true + delay(CarouselSettleDelayMs) + isCarouselSettling = false + pendingCarouselSettle = false + } + isScrollAnimating = false + } + + // Keep showing the finger position until the spring coroutine snaps scrollOffset — avoids a + // one-frame jump back to the pre-drag scrollOffset when isUserDragging flips false. + val displayScrollIndex = when { + isUserDragging -> dragScroll + !releaseFrom.isNaN() -> releaseFrom + else -> scrollOffset.value + } + val isCarouselMoving = isUserDragging || isScrollAnimating || isCarouselSettling || scrollOffset.isRunning + + fun resetFocusedPress() { + isUserPressing = false + scope.launch { + focusedPressCharge.animateTo(0f, spring(dampingRatio = 0.78f, stiffness = 500f)) + } + } + + fun pauseCarouselForUserInteraction() { + pendingCarouselSettle = false + isCarouselSettling = false + scope.launch { + scrollOffset.stop() + val settled = normalizeScrollIndex( + if (isUserDragging) dragScroll else scrollOffset.value, + features.size + ) + scrollTarget = settled + scrollOffset.snapTo(settled) + isScrollAnimating = false + } + } + + fun beginFocusedPress() { + if (isUserPressing) return + isUserPressing = true + pauseCarouselForUserInteraction() + scope.launch { + focusedPressCharge.animateTo( + 1f, + tween( + durationMillis = 380, + easing = CubicBezierEasing(0.23f, 1f, 0.32f, 1f) + ) + ) + } + } + + fun setUserPressing(pressing: Boolean) { + if (pressing == isUserPressing) return + isUserPressing = pressing + } + + fun setImageRevealed(revealed: Boolean) { + if (revealed == isImageRevealed) return + if (revealed && isUserDragging) return + isImageRevealed = revealed + } + + suspend fun waitForScrollToSettle() { + if (!scrollOffset.isRunning && !isScrollAnimating) return + snapshotFlow { scrollOffset.isRunning || isScrollAnimating } + .filter { !it } + .first() + } + + LaunchedEffect(isActive, reduceMotion, contentRevealed) { + if (!isActive || !contentRevealed || reduceMotion || features.size <= 1) return@LaunchedEffect + delay(initialAdvanceDelayMs) + while (isActive && contentRevealed) { + if (isUserDragging || isImageRevealed || isUserPressing) { + delay(200) + continue + } + isScrollAnimating = true + scrollTarget += 1f + pendingCarouselSettle = true + waitForScrollToSettle() + delay(HoldDurationMs) + } + } + + LaunchedEffect(isImageRevealed) { + if (isImageRevealed) resetFocusedPress() + } + + LaunchedEffect(isUserDragging) { + if (!isUserDragging) return@LaunchedEffect + resetFocusedPress() + setImageRevealed(false) + } + + if (reduceMotion) { + StaticFocusedCard( + feature = features[wrappedIndex(scrollTarget.roundToInt(), features.size)], + isImageRevealed = isImageRevealed, + onRevealChanged = { setImageRevealed(it) }, + onBeginPress = { beginFocusedPress() }, + onResetPress = { resetFocusedPress() }, + focusedPressCharge = focusedPressCharge.value, + onNavigateBy = { delta -> + scrollTarget = normalizeScrollIndex( + scrollTarget.roundToInt() + delta.toFloat(), + features.size + ) + }, + modifier = modifier + ) + return + } + + BoxWithConstraints( + modifier = modifier + .fillMaxSize() + .semantics { + val feature = features[wrappedIndex(displayScrollIndex.roundToInt(), features.size)] + contentDescription = "${feature.title}. ${feature.subtitle}" + } + ) { + val cardSize = cardDimensions(maxWidth, maxHeight) + val cardWidthPx = with(density) { cardSize.width.toPx() } + val cardHeightPx = with(density) { cardSize.height.toPx() } + val cardStepPx = cardWidthPx + with(density) { CardInterCardGapDp.dp.toPx() } + val dragMinimumDistancePx = with(density) { CarouselDragMinimumDistanceDp.dp.toPx() } + val pressMaximumDistancePx = with(density) { PressMaximumDistanceDp.dp.toPx() } + val visibleIndices = visibleFeatureIndices(displayScrollIndex, features.size, isCarouselMoving) + + Box( + modifier = Modifier + .fillMaxSize() + .pointerInput(cardStepPx, features.size, dragMinimumDistancePx, pressMaximumDistancePx, cardWidthPx, cardHeightPx) { + val velocityTracker = VelocityTracker() + var totalDragPx = 0f + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + val pointerId = down.id + val start = down.position + var dragStarted = false + var pressStarted = false + var cancelled = false + + val centerX = size.width / 2f + val centerY = size.height / 2f + val downOnFocusedCard = abs(start.x - centerX) <= cardWidthPx / 2f && + abs(start.y - centerY) <= cardHeightPx / 2f + + val holdJob = scope.launch { + if (!downOnFocusedCard) return@launch + delay(PressStartDelayMs) + if (cancelled || dragStarted) return@launch + pressStarted = true + beginFocusedPress() + delay(RevealHoldDurationMs) + if (!cancelled && !dragStarted && pressStarted) { + setImageRevealed(true) + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + } + } + + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == pointerId } ?: break + if (!change.pressed) { + holdJob.cancel() + if (dragStarted) { + val anchor = dragOriginScrollIndex + val flickDistance = velocityTracker.calculateVelocity().x * 0.22f + val projected = dragScroll - flickDistance / cardStepPx + val target = projected + .roundToInt() + .toFloat() + .coerceIn(anchor.roundToInt() - 1f, anchor.roundToInt() + 1f) + releaseFrom = dragScroll + scrollTarget = target + isUserDragging = false + pendingCarouselSettle = true + } else { + resetFocusedPress() + setImageRevealed(false) + } + break + } + + val distance = hypot( + change.position.x - start.x, + change.position.y - start.y + ) + + if (!dragStarted && !isImageRevealed) { + if (distance >= dragMinimumDistancePx) { + holdJob.cancel() + if (pressStarted) { + resetFocusedPress() + } + dragStarted = true + totalDragPx = 0f + velocityTracker.resetTracking() + val currentScroll = scrollOffset.value + val normalized = normalizeScrollIndex(currentScroll, features.size) + dragScroll = normalized + dragOriginScrollIndex = normalized + scrollTarget = normalized + pendingCarouselSettle = false + isCarouselSettling = false + isUserDragging = true + isScrollAnimating = true + } else if (distance > pressMaximumDistancePx) { + cancelled = true + holdJob.cancel() + if (pressStarted) { + resetFocusedPress() + } + break + } + } + + if (!dragStarted) continue + + val delta = change.position.x - change.previousPosition.x + velocityTracker.addPosition(change.uptimeMillis, change.position) + totalDragPx += delta + val anchor = dragOriginScrollIndex + dragScroll = (anchor - totalDragPx / cardStepPx) + .coerceIn(anchor - 1f, anchor + 1f) + change.consume() + } + } + }, + contentAlignment = Alignment.Center + ) { + visibleIndices.forEach { index -> + val relative = modularRelative(index, displayScrollIndex, features.size) + val isFocused = abs(relative) < 0.05f + val isRevealed = isFocused && isImageRevealed + val shouldAmbientBob = isFocused && !isRevealed && !isUserDragging && !isCarouselMoving && !isUserPressing + + val baseScale = 1f - minOf(abs(relative), 1f) * 0.08f + val targetScale = if (isRevealed) 1.05f else baseScale + val animatedScale by animateFloatAsState( + targetValue = targetScale, + animationSpec = when { + isRevealed -> RevealSpring + else -> RevealDismissSpring + }, + label = "CardRevealScale" + ) + val scale = if (isCarouselMoving) targetScale else animatedScale + + val blurRadius = if (isRevealed) { + 0.dp + } else { + val distance = (abs(relative) - 0.12f).coerceAtLeast(0f) / 0.88f + (distance.pow(2) * 2.2f * 4).dp + } + + val opacity = 1f - minOf(abs(relative), 1f) * 0.28f + val offsetXPx = with(density) { cardStepOffset(relative, cardSize.width).toPx() } + + FeatureCarouselCard( + feature = features[index], + cardWidth = cardSize.width, + cardHeight = cardSize.height, + relativePosition = relative, + isRevealed = isRevealed, + shouldAmbientBob = shouldAmbientBob, + pressCharge = if (isFocused) focusedPressCharge.value else 0f, + modifier = Modifier + .width(cardSize.width) + .height(cardSize.height) + .graphicsLayer { + translationX = offsetXPx + scaleX = scale + scaleY = scale + alpha = opacity + } + .then( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && blurRadius > 0.dp) { + Modifier.blur(blurRadius) + } else { + Modifier + } + ) + .zIndex(if (isRevealed) 100f else 10f - abs(relative) * 5f) + ) + } + } + } +} + +@Composable +private fun StaticFocusedCard( + feature: OnboardingFeature, + isImageRevealed: Boolean, + onRevealChanged: (Boolean) -> Unit, + onBeginPress: () -> Unit, + onResetPress: () -> Unit, + focusedPressCharge: Float, + onNavigateBy: (Int) -> Unit, + modifier: Modifier = Modifier +) { + val density = LocalDensity.current + val haptics = LocalHapticFeedback.current + val scope = rememberCoroutineScope() + + BoxWithConstraints(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + val cardSize = cardDimensions(maxWidth, maxHeight) + val cardWidthPx = with(density) { cardSize.width.toPx() } + val cardHeightPx = with(density) { cardSize.height.toPx() } + val cardStepPx = cardWidthPx + with(density) { CardInterCardGapDp.dp.toPx() } + val dragMinimumDistancePx = with(density) { CarouselDragMinimumDistanceDp.dp.toPx() } + val pressMaximumDistancePx = with(density) { PressMaximumDistanceDp.dp.toPx() } + + Box( + modifier = Modifier + .fillMaxSize() + .semantics { + contentDescription = "${feature.title}. ${feature.subtitle}" + } + .pointerInput(cardStepPx, dragMinimumDistancePx, pressMaximumDistancePx, cardWidthPx, cardHeightPx) { + var totalDragPx = 0f + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + val pointerId = down.id + val start = down.position + var dragStarted = false + var pressStarted = false + var cancelled = false + + val centerX = size.width / 2f + val centerY = size.height / 2f + val downOnFocusedCard = abs(start.x - centerX) <= cardWidthPx / 2f && + abs(start.y - centerY) <= cardHeightPx / 2f + + val holdJob = scope.launch { + if (!downOnFocusedCard) return@launch + delay(PressStartDelayMs) + if (cancelled || dragStarted) return@launch + pressStarted = true + onBeginPress() + delay(RevealHoldDurationMs) + if (!cancelled && !dragStarted && pressStarted) { + onRevealChanged(true) + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + } + } + + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == pointerId } ?: break + if (!change.pressed) { + holdJob.cancel() + if (dragStarted) { + val targetDelta = when { + totalDragPx <= -cardStepPx * 0.28f -> 1 + totalDragPx >= cardStepPx * 0.28f -> -1 + else -> 0 + } + if (targetDelta != 0) { + onNavigateBy(targetDelta) + } + onResetPress() + onRevealChanged(false) + } else { + onResetPress() + onRevealChanged(false) + } + break + } + + val distance = hypot( + change.position.x - start.x, + change.position.y - start.y + ) + + if (!dragStarted && !isImageRevealed) { + if (distance >= dragMinimumDistancePx) { + holdJob.cancel() + if (pressStarted) { + onResetPress() + } + dragStarted = true + totalDragPx = 0f + } else if (distance > pressMaximumDistancePx) { + cancelled = true + holdJob.cancel() + if (pressStarted) { + onResetPress() + } + break + } + } + + if (!dragStarted) continue + + totalDragPx += change.position.x - change.previousPosition.x + change.consume() + } + } + }, + contentAlignment = Alignment.Center + ) { + FeatureCarouselCard( + feature = feature, + cardWidth = cardSize.width, + cardHeight = cardSize.height, + relativePosition = 0f, + isRevealed = isImageRevealed, + shouldAmbientBob = false, + pressCharge = focusedPressCharge, + modifier = Modifier.width(cardSize.width).height(cardSize.height) + ) + } + } +} + +@Composable +private fun FeatureCarouselCard( + feature: OnboardingFeature, + cardWidth: Dp, + cardHeight: Dp, + relativePosition: Float, + isRevealed: Boolean, + shouldAmbientBob: Boolean, + pressCharge: Float, + modifier: Modifier = Modifier +) { + val palette = OnboardingTheme.palette + val layout = remember(cardWidth, cardHeight) { CardLayoutMetrics(cardWidth, cardHeight) } + var ambientOffsetX by remember { mutableFloatStateOf(0f) } + var ambientOffsetY by remember { mutableFloatStateOf(0f) } + val bobAmplitude = remember { Animatable(0f) } + + val revealProgress by animateFloatAsState( + targetValue = if (isRevealed) 1f else 0f, + animationSpec = if (isRevealed) RevealSpring else RevealDismissSpring, + label = "CardReveal" + ) + + val cardBackground by animateColorAsState( + targetValue = palette.surfacePaper.copy(alpha = if (palette.isDark) 0.9f else 1f), + animationSpec = tween(280), + label = "CardBackground" + ) + val borderColor by animateColorAsState( + targetValue = palette.lineSoft.copy(alpha = if (palette.isDark) 0.45f else 0.85f), + animationSpec = tween(280), + label = "CardBorder" + ) + + val revealBackground = colorLerp(cardBackground, Color.Black, revealProgress) + val pressScale = when { + revealProgress > 0f -> lerp(if (pressCharge > 0f) 0.97f else 1f, 1f, revealProgress) + pressCharge > 0f -> 0.97f + else -> 1f + } + + val focusWeight = (1f - minOf(abs(relativePosition), 1f)).coerceIn(0f, 1f) + val shadowRadius = lerp(layout.sideShadowRadius.value, layout.focusedShadowRadius.value, focusWeight) + val shadowElevation = (shadowRadius + (shadowRadius * 1.15f - shadowRadius) * revealProgress).dp + val shadowAlpha = if (palette.isDark) 0.35f else 0.12f + focusWeight * 0.06f + + LaunchedEffect(shouldAmbientBob) { + bobAmplitude.animateTo( + targetValue = if (shouldAmbientBob) 1f else 0f, + animationSpec = if (shouldAmbientBob) { + tween(durationMillis = AmbientBobRampInMs, easing = AmbientBobEaseIn) + } else { + tween(durationMillis = AmbientBobRampOutMs, easing = AmbientBobEaseOut) + } + ) + } + + LaunchedEffect(Unit) { + while (true) { + withFrameNanos { timeNanos -> + val t = timeNanos / 1_000_000_000.0 + val amplitude = bobAmplitude.value + ambientOffsetX = (sin(t * 0.55) * 2.5f * amplitude).toFloat() + ambientOffsetY = (sin(t * 0.72 + 0.6) * 4f * amplitude).toFloat() + } + } + } + + Box( + modifier = modifier + .shadow( + elevation = shadowElevation, + shape = RoundedCornerShape(layout.cornerRadius), + ambientColor = Color.Black.copy(alpha = shadowAlpha), + spotColor = Color.Black.copy(alpha = shadowAlpha), + clip = false + ) + .graphicsLayer { + translationX = ambientOffsetX + translationY = ambientOffsetY + } + .scale(pressScale) + .clip(RoundedCornerShape(layout.cornerRadius)) + .background(revealBackground) + .border(1.dp, borderColor, RoundedCornerShape(layout.cornerRadius)) + ) { + val imageSpanHeight = layout.heroHeight + layout.transitionBlendHeight + Box( + modifier = Modifier + .width(cardWidth) + .height(cardHeight) + ) { + Box( + modifier = Modifier + .matchParentSize() + .graphicsLayer { alpha = 1f - revealProgress } + ) { + Box( + modifier = Modifier + .width(cardWidth) + .height(imageSpanHeight) + .align(Alignment.TopCenter) + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + drawRect( + brush = heroFadeMaskBrush(layout), + blendMode = BlendMode.DstIn + ) + } + ) { + HeroImageStack( + feature = feature, + cardWidth = cardWidth, + totalHeight = imageSpanHeight, + layout = layout, + relativePosition = relativePosition, + pressCharge = pressCharge + ) + } + + CopyPaperBacking( + layout = layout, + modifier = Modifier.align(Alignment.TopCenter) + ) + + CopySection( + feature = feature, + layout = layout, + pressCharge = pressCharge, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = layout.heroHeight + layout.copySectionOffset) + ) + + if (pressCharge > 0f && revealProgress == 0f) { + Box( + modifier = Modifier + .matchParentSize() + .border( + width = (1f + pressCharge * 0.6f).dp, + color = palette.textPrimary.copy(alpha = 0.08f + pressCharge * 0.14f), + shape = RoundedCornerShape(layout.cornerRadius) + ) + ) + } + } + + if (revealProgress > 0f) { + HeroImage( + feature = feature, + cardWidth = cardWidth, + height = cardHeight, + relativePosition = relativePosition, + parallaxScale = lerp(1f, 0.22f, revealProgress), + modifier = Modifier.graphicsLayer { alpha = revealProgress } + ) + } + } + } +} + +@Composable +private fun HeroImageStack( + feature: OnboardingFeature, + cardWidth: Dp, + totalHeight: Dp, + layout: CardLayoutMetrics, + relativePosition: Float, + pressCharge: Float +) { + val palette = OnboardingTheme.palette + val blendHeight = layout.transitionBlendHeight * 1.6f + val imageScale = 1f + pressCharge * 0.05f + Box( + modifier = Modifier + .width(cardWidth) + .height(totalHeight) + .scale(imageScale) + ) { + HeroImage( + feature = feature, + cardWidth = cardWidth, + height = totalHeight, + relativePosition = relativePosition, + parallaxScale = 1f + ) + + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(blendHeight) + .background( + Brush.verticalGradient( + colorStops = arrayOf( + 0f to Color.Transparent, + 0.45f to palette.surfacePaper.copy(alpha = if (palette.isDark) 0.22f else 0.28f), + 1f to palette.surfacePaper.copy(alpha = if (palette.isDark) 0.55f else 0.62f) + ) + ) + ) + ) + } +} + +@Composable +private fun HeroImage( + feature: OnboardingFeature, + cardWidth: Dp, + height: Dp, + relativePosition: Float, + parallaxScale: Float, + modifier: Modifier = Modifier +) { + Image( + painter = painterResource(feature.imageRes), + contentDescription = feature.title, + modifier = modifier + .fillMaxWidth() + .height(height) + .graphicsLayer { + translationX = relativePosition * -14f * parallaxScale + translationY = relativePosition * -5f * parallaxScale + }, + contentScale = ContentScale.Crop + ) +} + +@Composable +private fun CopyPaperBacking( + layout: CardLayoutMetrics, + modifier: Modifier = Modifier +) { + val palette = OnboardingTheme.palette + Column(modifier = modifier.fillMaxWidth()) { + Spacer(modifier = Modifier.height(layout.heroHeight - layout.transitionBlendHeight * 0.45f)) + Box( + modifier = Modifier + .fillMaxWidth() + .height(layout.copyHeight + layout.transitionBlendHeight * 0.55f) + .background( + Brush.verticalGradient( + colorStops = arrayOf( + 0f to palette.surfacePaper.copy(alpha = 0f), + 0.18f to palette.surfacePaper.copy(alpha = if (palette.isDark) 0.38f else 0.48f), + 0.42f to palette.surfacePaper.copy(alpha = if (palette.isDark) 0.78f else 0.86f), + 0.72f to palette.surfacePaper, + 1f to palette.surfacePaper + ) + ) + ) + ) + } +} + +@Composable +private fun CopySection( + feature: OnboardingFeature, + layout: CardLayoutMetrics, + pressCharge: Float, + modifier: Modifier = Modifier +) { + val palette = OnboardingTheme.palette + val copyAlpha = 1f - pressCharge * 0.42f + val copyBlur = (pressCharge * 1.6f).dp + Column( + modifier = modifier + .fillMaxWidth() + .height(layout.copyHeight) + .graphicsLayer { alpha = copyAlpha } + .then( + if (pressCharge > 0f && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + Modifier.blur(copyBlur) + } else { + Modifier + } + ) + .padding(horizontal = layout.horizontalPadding) + .padding(top = layout.copyTopPadding, bottom = layout.copyBottomPadding) + ) { + Text( + text = feature.title, + color = palette.textPrimary, + fontSize = layout.titleFontSize, + fontWeight = FontWeight.SemiBold, + lineHeight = layout.titleFontSize * 1.15f + ) + Text( + text = feature.subtitle, + color = palette.textPrimary.copy(alpha = if (palette.isDark) 0.82f else 0.78f), + fontSize = layout.subtitleFontSize, + fontWeight = FontWeight.Medium, + lineHeight = layout.subtitleFontSize * 1.2f, + modifier = Modifier.padding(top = layout.textSpacing) + ) + Text( + text = feature.description, + color = palette.textSecondary, + fontSize = layout.bodyFontSize, + lineHeight = layout.bodyFontSize * 1.35f, + modifier = Modifier.padding(top = layout.textSpacing) + ) + } +} + +private fun heroFadeMaskBrush(layout: CardLayoutMetrics): Brush { + val split = (layout.heroHeight / (layout.heroHeight + layout.transitionBlendHeight)).coerceIn(0f, 1f) + return Brush.verticalGradient( + colorStops = arrayOf( + 0f to Color.White, + split * 0.94f to Color.White, + split to Color.White.copy(alpha = 0.82f), + (split + 0.55f * (1f - split)).coerceAtMost(1f) to Color.White.copy(alpha = 0.18f), + 1f to Color.Transparent + ) + ) +} + +internal fun cardStepOffset(relative: Float, cardWidth: Dp): Dp { + return (cardWidth + CardInterCardGapDp.dp) * relative +} + +private data class CardLayoutMetrics( + val cardWidth: Dp, + val cardHeight: Dp +) { + val heroHeight: Dp = cardHeight * 0.60f + val copyHeight: Dp = cardHeight * 0.40f + val copySectionOffset: Dp = cardHeight * 0.035f + val transitionBlendHeight: Dp = cardHeight * 0.10f + val cornerRadius: Dp = cardWidth * 0.074f + val titleFontSize = (cardWidth.value * 0.052f).coerceAtLeast(18f).sp + val subtitleFontSize = (cardWidth.value * 0.041f).coerceAtLeast(14f).sp + val bodyFontSize = (cardWidth.value * 0.038f).coerceAtLeast(13f).sp + val horizontalPadding: Dp = cardWidth * 0.058f + val textSpacing: Dp = cardHeight * 0.016f + val copyTopPadding: Dp = copyHeight * 0.06f + val copyBottomPadding: Dp = copyHeight * 0.11f + val focusedShadowRadius: Dp = cardWidth * 0.062f + val focusedShadowY: Dp = cardHeight * 0.024f + val sideShadowRadius: Dp = cardWidth * 0.028f + val sideShadowY: Dp = cardHeight * 0.010f +} + +private data class CardDimensions(val width: Dp, val height: Dp) + +@Composable +private fun cardDimensions(maxWidth: Dp, maxHeight: Dp): CardDimensions { + val height = maxHeight * CardHeightRatio + val width = minOf(maxWidth * CardWidthRatio, height / CardAspectRatio) + return CardDimensions(width = width, height = height) +} + +internal fun modularRelative(featureIndex: Int, scroll: Float, count: Int): Float { + if (count <= 1) return featureIndex - scroll + val base = featureIndex - scroll + var best = base + for (shift in listOf(-count, 0, count)) { + val candidate = base + shift + if (abs(candidate) < abs(best)) best = candidate + } + return best +} + +internal fun wrappedIndex(index: Int, count: Int): Int { + if (count <= 0) return 0 + return ((index % count) + count) % count +} + +internal fun normalizeScrollIndex(scroll: Float, count: Int): Float { + if (count <= 0) return scroll + var normalized = scroll % count + if (normalized < 0) normalized += count + return normalized +} + +private fun visibleFeatureIndices(scroll: Float, count: Int, isCarouselMoving: Boolean): List { + val window = if (isCarouselMoving) 1.2f else 1.05f + return (0 until count).filter { abs(modularRelative(it, scroll, count)) <= window } +} diff --git a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/components/Badge.kt b/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/components/Badge.kt deleted file mode 100644 index 697ff344..00000000 --- a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/components/Badge.kt +++ /dev/null @@ -1,107 +0,0 @@ -package io.github.bengidev.opencore.onboarding.presenter.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -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.filled.AutoAwesome -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.Shield -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import io.github.bengidev.opencore.onboarding.domain.OnboardingPageType -import io.github.bengidev.opencore.onboarding.theme.OnboardingTheme - -/** Eyebrow badge — iOS Badge.swift: subtle capsule, mono label, accent when active. */ -@Composable -internal fun Badge( - text: String, - icon: ImageVector? = null, - modifier: Modifier = Modifier, - isActive: Boolean = true -) { - val palette = OnboardingTheme.palette - - Row( - modifier = modifier - .clip(RoundedCornerShape(999.dp)) - .background(palette.surfaceSubtle.copy(alpha = 0.5f)) - .border( - width = 1.dp, - color = if (isActive) palette.accentPrimary.copy(alpha = 0.4f) else palette.lineSoft, - shape = RoundedCornerShape(999.dp) - ) - .padding(horizontal = 10.dp, vertical = 7.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .size(5.dp) - .clip(CircleShape) - .background(if (isActive) palette.accentPrimary else palette.textTertiary) - ) - if (icon != null) { - Spacer(modifier = Modifier.width(7.dp)) - Icon( - imageVector = icon, - contentDescription = null, - tint = if (isActive) palette.accentPrimary else palette.textSecondary, - modifier = Modifier.size(10.dp) - ) - } - Spacer(modifier = Modifier.width(7.dp)) - Text( - text = text.uppercase(), - style = OnboardingTheme.typography.monoXs, - color = if (isActive) palette.accentPrimary else palette.textSecondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } -} - -/** Page code chip (SEC-01, AI-02…). */ -@Composable -internal fun IndexLabelChip( - label: String, - modifier: Modifier = Modifier -) { - val palette = OnboardingTheme.palette - - Text( - text = label, - style = OnboardingTheme.typography.monoXs, - color = palette.accentPrimary, - modifier = modifier - .clip(RoundedCornerShape(6.dp)) - .background(palette.accentSoft) - .border(1.dp, palette.accentPrimary.copy(alpha = 0.28f), RoundedCornerShape(6.dp)) - .padding(horizontal = 10.dp, vertical = 7.dp) - ) -} - -internal fun iconForPageType(type: OnboardingPageType): ImageVector { - return when (type) { - OnboardingPageType.EncryptedPairing -> Icons.Filled.Shield - OnboardingPageType.IdeaStudio -> Icons.Filled.AutoAwesome - OnboardingPageType.PromptQueue -> Icons.Filled.PlayArrow - OnboardingPageType.ReasoningControl -> Icons.Filled.Settings - OnboardingPageType.WorkspaceReady -> Icons.Filled.Lock - } -} diff --git a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/components/BottomNavigation.kt b/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/components/BottomNavigation.kt deleted file mode 100644 index dedda493..00000000 --- a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/components/BottomNavigation.kt +++ /dev/null @@ -1,74 +0,0 @@ -package io.github.bengidev.opencore.onboarding.presenter.components - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.ArrowForward -import androidx.compose.material3.Icon -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import io.github.bengidev.opencore.onboarding.theme.OnboardingTheme - -/** Bottom navigation — iOS OnboardingBottomNavigationView (24dp gap, 52dp buttons). */ -@Composable -internal fun OnboardingBottomNavigation( - currentPage: Int, - totalPages: Int, - isLastPage: Boolean, - onNext: () -> Unit, - onPrevious: () -> Unit, - onFinish: () -> Unit, - onPageSelected: (Int) -> Unit, - modifier: Modifier = Modifier -) { - Column( - modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(24.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - OnboardingProgressIndicator( - currentPage = currentPage, - totalPages = totalPages, - onPageSelected = onPageSelected - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically - ) { - if (currentPage > 0) { - SecondaryButton( - text = "BACK", - onClick = onPrevious, - modifier = Modifier.weight(1f), - leadingIcon = { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - tint = OnboardingTheme.palette.textPrimary - ) - } - ) - } - - PrimaryButton( - text = if (isLastPage) "ENTER OPENCORE" else "CONTINUE", - onClick = if (isLastPage) onFinish else onNext, - modifier = Modifier.weight(1f), - trailingIcon = { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowForward, - contentDescription = null, - tint = OnboardingTheme.palette.controlStrongText - ) - } - ) - } - } -} diff --git a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/components/Buttons.kt b/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/components/Buttons.kt deleted file mode 100644 index 5eb65026..00000000 --- a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/components/Buttons.kt +++ /dev/null @@ -1,143 +0,0 @@ -package io.github.bengidev.opencore.onboarding.presenter.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import io.github.bengidev.opencore.onboarding.theme.OnboardingTheme - -/** Primary CTA — iOS PrimaryButtonStyle: 52dp, 8dp radius, mono semibold. */ -@Composable -internal fun PrimaryButton( - text: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - leadingIcon: @Composable (() -> Unit)? = null, - trailingIcon: @Composable (() -> Unit)? = null -) { - val palette = OnboardingTheme.palette - val bgColor = if (enabled) palette.controlStrong else palette.controlStrong.copy(alpha = 0.4f) - val textColor = if (enabled) palette.controlStrongText else palette.controlStrongText.copy(alpha = 0.4f) - - Box( - modifier = modifier - .height(52.dp) - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(bgColor) - .clickable(enabled = enabled, onClick = onClick), - contentAlignment = Alignment.Center - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 16.dp) - ) { - if (leadingIcon != null) { - leadingIcon() - Spacer(modifier = Modifier.width(8.dp)) - } - Text( - text = text.uppercase(), - style = OnboardingTheme.typography.labelMd, - color = textColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - if (trailingIcon != null) { - Spacer(modifier = Modifier.width(9.dp)) - trailingIcon() - } - } - } -} - -/** Secondary CTA — iOS SecondaryButtonStyle. */ -@Composable -internal fun SecondaryButton( - text: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - leadingIcon: @Composable (() -> Unit)? = null, - trailingIcon: @Composable (() -> Unit)? = null -) { - val palette = OnboardingTheme.palette - val textColor = if (enabled) palette.textPrimary else palette.textTertiary - - Box( - modifier = modifier - .height(52.dp) - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(palette.surfaceRaised.copy(alpha = 0.8f)) - .border(1.dp, palette.lineSoft, RoundedCornerShape(8.dp)) - .clickable(enabled = enabled, onClick = onClick), - contentAlignment = Alignment.Center - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 16.dp) - ) { - if (leadingIcon != null) { - leadingIcon() - Spacer(modifier = Modifier.width(8.dp)) - } - Text( - text = text.uppercase(), - style = OnboardingTheme.typography.labelMd, - color = textColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - if (trailingIcon != null) { - Spacer(modifier = Modifier.width(8.dp)) - trailingIcon() - } - } - } -} - -/** Segmented chip — iOS idea studio tabs (6dp corner). */ -@Composable -internal fun MonoChipButton( - text: String, - selected: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier -) { - val palette = OnboardingTheme.palette - val bgColor = if (selected) palette.controlStrong else palette.surfaceSubtle.copy(alpha = 0.4f) - val textColor = if (selected) palette.controlStrongText else palette.textSecondary - - Box( - modifier = modifier - .clip(RoundedCornerShape(6.dp)) - .background(bgColor) - .clickable(onClick = onClick) - .padding(vertical = 8.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = text.uppercase(), - style = OnboardingTheme.typography.monoXs, - color = textColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } -} diff --git a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/components/CardChrome.kt b/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/components/CardChrome.kt deleted file mode 100644 index 645651d2..00000000 --- a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/components/CardChrome.kt +++ /dev/null @@ -1,31 +0,0 @@ -package io.github.bengidev.opencore.onboarding.presenter.components - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.OutlinedCard -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import io.github.bengidev.opencore.onboarding.theme.OnboardingTheme - -/** iOS CardChrome — thin border, paper fill, no shadow. */ -@Composable -internal fun CardChrome( - modifier: Modifier = Modifier, - cornerRadius: Dp = 12.dp, - content: @Composable ColumnScope.() -> Unit -) { - val palette = OnboardingTheme.palette - val shape = RoundedCornerShape(cornerRadius) - - OutlinedCard( - modifier = modifier, - shape = shape, - colors = CardDefaults.outlinedCardColors(containerColor = palette.surfacePaper), - border = BorderStroke(1.dp, palette.lineSoft), - content = content, - ) -} diff --git a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/components/HighlightFooter.kt b/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/components/HighlightFooter.kt deleted file mode 100644 index 70121ff7..00000000 --- a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/components/HighlightFooter.kt +++ /dev/null @@ -1,128 +0,0 @@ -package io.github.bengidev.opencore.onboarding.presenter.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -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.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowForward -import androidx.compose.material.icons.filled.AutoAwesome -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Layers -import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.Person -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.Shield -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import io.github.bengidev.opencore.onboarding.domain.OnboardingFeatureHighlight -import io.github.bengidev.opencore.onboarding.theme.OnboardingTheme -import io.github.bengidev.opencore.ui.components.ScaleToFitText - -/** Two-tile highlight footer inside demo card — iOS highlightFooter. */ -@Composable -internal fun HighlightFooter( - highlights: List, - modifier: Modifier = Modifier, - appeared: Boolean = true -) { - Row( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - highlights.take(2).forEachIndexed { index, highlight -> - HighlightTile( - highlight = highlight, - isFirst = index == 0, - modifier = Modifier.weight(1f) - ) - } - } -} - -@Composable -private fun HighlightTile( - highlight: OnboardingFeatureHighlight, - isFirst: Boolean, - modifier: Modifier = Modifier -) { - val palette = OnboardingTheme.palette - val iconBg = if (isFirst) palette.accentSoft else palette.surfaceSubtle.copy(alpha = 0.4f) - val iconTint = if (isFirst) palette.accentPrimary else palette.textSecondary - - Row( - modifier = modifier - .clip(RoundedCornerShape(8.dp)) - .background(palette.surfacePaper) - .border(1.dp, palette.lineSoft, RoundedCornerShape(8.dp)) - .padding(9.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .size(28.dp) - .clip(RoundedCornerShape(6.dp)) - .background(iconBg), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = iconForHighlight(highlight.title), - contentDescription = null, - tint = iconTint, - modifier = Modifier.size(14.dp) - ) - } - - Spacer(modifier = Modifier.width(9.dp)) - - Column(modifier = Modifier.weight(1f)) { - Text( - text = highlight.title.uppercase(), - style = OnboardingTheme.typography.monoXs, - color = palette.textPrimary, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - ScaleToFitText( - text = highlight.detail, - style = OnboardingTheme.typography.monoXs.copy( - fontWeight = androidx.compose.ui.text.font.FontWeight.Normal - ), - color = palette.textTertiary, - maxLines = 1, - minFontSize = 8.sp - ) - } - } -} - -private fun iconForHighlight(title: String): ImageVector { - return when (title.lowercase()) { - "local memory" -> Icons.Filled.Menu - "secure session" -> Icons.Filled.Shield - "design canvas" -> Icons.Filled.Layers - "ai assistance" -> Icons.Filled.AutoAwesome - "state engine" -> Icons.Filled.Menu - "run steering" -> Icons.AutoMirrored.Filled.ArrowForward - "model controls" -> Icons.Filled.Settings - "human steering" -> Icons.Filled.Person - else -> Icons.Filled.PlayArrow - } -} diff --git a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/components/ProgressIndicator.kt b/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/components/ProgressIndicator.kt deleted file mode 100644 index 831684c4..00000000 --- a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/components/ProgressIndicator.kt +++ /dev/null @@ -1,46 +0,0 @@ -package io.github.bengidev.opencore.onboarding.presenter.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.unit.dp -import io.github.bengidev.opencore.onboarding.theme.OnboardingTheme - -/** iOS-matched progress: blue pill for active page, muted dots for others. */ -@Composable -internal fun OnboardingProgressIndicator( - currentPage: Int, - totalPages: Int, - onPageSelected: (Int) -> Unit, - modifier: Modifier = Modifier -) { - val palette = OnboardingTheme.palette - - Row( - modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - for (index in 0 until totalPages) { - val isCurrent = index == currentPage - - Box( - modifier = Modifier - .height(6.dp) - .width(if (isCurrent) 28.dp else 6.dp) - .clip(RoundedCornerShape(percent = 50)) - .background(if (isCurrent) palette.accentPrimary else palette.lineSoft) - .clickable { onPageSelected(index) } - ) - } - } -} diff --git a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/components/ReasoningLevelSlider.kt b/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/components/ReasoningLevelSlider.kt deleted file mode 100644 index 7e0009ce..00000000 --- a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/components/ReasoningLevelSlider.kt +++ /dev/null @@ -1,100 +0,0 @@ -package io.github.bengidev.opencore.onboarding.presenter.components - -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.gestures.detectDragGestures -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.dp -import io.github.bengidev.opencore.onboarding.theme.OnboardingTheme - -/** iOS-matched reasoning slider — single canvas + absolute drag/tap positioning. */ -@Composable -internal fun ReasoningLevelSlider( - value: Float, - onValueChange: (Float) -> Unit, - modifier: Modifier = Modifier -) { - val palette = OnboardingTheme.palette - val fraction = value.coerceIn(0f, 1f) - val density = LocalDensity.current - val thumbWidthDp = 24.dp - val thumbHeightDp = 20.dp - val trackHeightDp = 5.dp - - BoxWithConstraints( - modifier = modifier - .fillMaxWidth() - .height(44.dp) - ) { - val thumbWidthPx = with(density) { thumbWidthDp.toPx() } - val thumbHeightPx = with(density) { thumbHeightDp.toPx() } - val trackHeightPx = with(density) { trackHeightDp.toPx() } - val travelPx = with(density) { (maxWidth - thumbWidthDp).toPx() } - - fun fractionFromX(x: Float): Float { - if (travelPx <= 0f) return fraction - val centered = x - thumbWidthPx / 2f - return (centered / travelPx).coerceIn(0f, 1f) - } - - Canvas( - modifier = Modifier - .fillMaxSize() - .pointerInput(travelPx, thumbWidthPx) { - detectDragGestures { change, _ -> - change.consume() - onValueChange(fractionFromX(change.position.x)) - } - } - .pointerInput(travelPx, thumbWidthPx) { - detectTapGestures { offset -> - onValueChange(fractionFromX(offset.x)) - } - } - ) { - val centerY = size.height / 2f - val trackTop = centerY - trackHeightPx / 2f - val thumbOffset = travelPx * fraction - val activeWidth = (thumbOffset + thumbWidthPx / 2f).coerceAtLeast(trackHeightPx) - val trackRadius = CornerRadius(trackHeightPx / 2f, trackHeightPx / 2f) - val thumbRadius = CornerRadius(with(density) { 10.dp.toPx() }) - - drawRoundRect( - color = palette.lineSoft.copy(alpha = 0.75f), - topLeft = Offset(0f, trackTop), - size = Size(size.width, trackHeightPx), - cornerRadius = trackRadius - ) - drawRoundRect( - color = palette.accentPrimary, - topLeft = Offset(0f, trackTop), - size = Size(activeWidth, trackHeightPx), - cornerRadius = trackRadius - ) - drawRoundRect( - color = palette.surfaceRaised, - topLeft = Offset(thumbOffset, centerY - thumbHeightPx / 2f), - size = Size(thumbWidthPx, thumbHeightPx), - cornerRadius = thumbRadius - ) - drawRoundRect( - color = palette.lineSoft, - topLeft = Offset(thumbOffset, centerY - thumbHeightPx / 2f), - size = Size(thumbWidthPx, thumbHeightPx), - cornerRadius = thumbRadius, - style = Stroke(width = with(density) { 1.dp.toPx() }) - ) - } - } -} diff --git a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/components/ShimmerText.kt b/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/components/ShimmerText.kt new file mode 100644 index 00000000..81637db9 --- /dev/null +++ b/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/components/ShimmerText.kt @@ -0,0 +1,85 @@ +package io.github.bengidev.opencore.onboarding.presenter.components + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import io.github.bengidev.opencore.onboarding.theme.OnboardingTheme + +private const val SweepDurationMs = 1_750 +private const val SweepBandWidthRatio = 0.55f + +@Composable +internal fun ShimmerText( + text: String, + baseColor: Color, + isActive: Boolean, + reduceMotion: Boolean, + modifier: Modifier = Modifier, + style: TextStyle = LocalTextStyle.current +) { + val palette = OnboardingTheme.palette + val highlightColor = if (palette.isDark) Color(0xFFF5F5F5) else Color.White + val density = LocalDensity.current + var textWidthPx by remember { mutableFloatStateOf(0f) } + + if (!isActive || reduceMotion) { + Text( + text = text, + color = baseColor, + style = style, + modifier = modifier + ) + return + } + + val transition = rememberInfiniteTransition(label = "ShimmerText") + val shimmerOffset by transition.animateFloat( + initialValue = -1f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = SweepDurationMs, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), + label = "ShimmerOffset" + ) + + val bandWidthPx = maxOf(textWidthPx * SweepBandWidthRatio, with(density) { 24.dp.toPx() }) + val startX = shimmerOffset * (textWidthPx + bandWidthPx) + + Text( + text = text, + modifier = modifier, + onTextLayout = { layoutResult -> textWidthPx = layoutResult.size.width.toFloat() }, + style = style.copy( + brush = Brush.linearGradient( + colorStops = arrayOf( + 0f to baseColor, + 0.38f to lerp(baseColor, highlightColor.copy(alpha = 0.2f), 1f), + 0.5f to highlightColor.copy(alpha = 0.95f), + 0.62f to lerp(baseColor, highlightColor.copy(alpha = 0.2f), 1f), + 1f to baseColor + ), + start = Offset(startX, 0f), + end = Offset(startX + bandWidthPx, 0f) + ) + ) + ) +} diff --git a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/components/SwipeToStartView.kt b/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/components/SwipeToStartView.kt new file mode 100644 index 00000000..2f777c35 --- /dev/null +++ b/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/components/SwipeToStartView.kt @@ -0,0 +1,205 @@ +package io.github.bengidev.opencore.onboarding.presenter.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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.ArrowForward +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.github.bengidev.opencore.onboarding.theme.OnboardingTheme +import kotlinx.coroutines.launch + +private val TrackHeight = 58.dp +private val KnobSize = 46.dp +private val InnerInset = 6.dp +private const val TrailingUnlockThreshold = 0.92f + +@Composable +internal fun SwipeToStartView( + isUnlocked: Boolean, + reduceMotion: Boolean, + onComplete: suspend () -> Boolean, + modifier: Modifier = Modifier +) { + val palette = OnboardingTheme.palette + val haptics = LocalHapticFeedback.current + val scope = rememberCoroutineScope() + var dragOffsetPx by remember { mutableFloatStateOf(0f) } + var isDragging by remember { mutableStateOf(false) } + var isCompleting by remember { mutableStateOf(false) } + val density = LocalDensity.current + + val trackColor by animateColorAsState( + targetValue = palette.surfaceSubtle, + animationSpec = tween(280), + label = "SwipeTrack" + ) + val borderColor by animateColorAsState( + targetValue = palette.lineSoft, + animationSpec = tween(280), + label = "SwipeBorder" + ) + val labelColor by animateColorAsState( + targetValue = palette.textTertiary, + animationSpec = tween(280), + label = "SwipeLabel" + ) + val knobColor by animateColorAsState( + targetValue = palette.controlStrong, + animationSpec = tween(280), + label = "SwipeKnob" + ) + val knobIconColor by animateColorAsState( + targetValue = palette.controlStrongText, + animationSpec = tween(280), + label = "SwipeKnobIcon" + ) + + val knobScale by animateFloatAsState( + targetValue = if (isDragging) 0.97f else 1f, + animationSpec = spring( + dampingRatio = 0.72f, + stiffness = Spring.StiffnessMedium + ), + label = "SwipeKnobScale" + ) + + val animatedDrag by animateFloatAsState( + targetValue = dragOffsetPx, + animationSpec = when { + reduceMotion -> spring(stiffness = Spring.StiffnessHigh) + isCompleting -> spring(dampingRatio = 0.72f, stiffness = Spring.StiffnessMedium) + else -> spring(dampingRatio = 0.68f, stiffness = Spring.StiffnessMediumLow) + }, + label = "SwipeDragOffset" + ) + + BoxWithConstraints( + modifier = modifier + .fillMaxWidth() + .height(TrackHeight) + .semantics { contentDescription = "Swipe to get started" } + ) { + val maxWidthPx = with(density) { maxWidth.toPx() } + val knobSizePx = with(density) { KnobSize.toPx() } + val innerInsetPx = with(density) { InnerInset.toPx() } + val maxDrag = (maxWidthPx - knobSizePx - innerInsetPx * 2).coerceAtLeast(1f) + val labelOpacity = (1f - animatedDrag / maxDrag).coerceIn(0f, 1f) + + Box( + modifier = Modifier + .matchParentSize() + .clip(RoundedCornerShape(percent = 50)) + .background(trackColor) + .border(1.dp, borderColor, RoundedCornerShape(percent = 50)) + .pointerInput(isUnlocked, isCompleting) { + if (isUnlocked || isCompleting) return@pointerInput + detectHorizontalDragGestures( + onDragStart = { + isDragging = true + haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) + }, + onDragEnd = { + isDragging = false + val reachedTrailing = dragOffsetPx >= maxDrag * TrailingUnlockThreshold + if (reachedTrailing) { + scope.launch { + if (isUnlocked || isCompleting) return@launch + isCompleting = true + dragOffsetPx = maxDrag + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + val succeeded = onComplete() + if (!succeeded) { + dragOffsetPx = 0f + haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } + isCompleting = false + } + } else { + dragOffsetPx = 0f + haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } + }, + onDragCancel = { + isDragging = false + dragOffsetPx = 0f + }, + onHorizontalDrag = { _, dragAmount -> + if (isUnlocked || isCompleting) return@detectHorizontalDragGestures + dragOffsetPx = (dragOffsetPx + dragAmount).coerceIn(0f, maxDrag) + } + ) + } + ) { + ShimmerText( + text = "swipe to get started", + baseColor = labelColor, + isActive = !isUnlocked && labelOpacity > 0.15f, + reduceMotion = reduceMotion, + modifier = Modifier + .align(Alignment.Center) + .graphicsLayer { alpha = labelOpacity }, + style = androidx.compose.ui.text.TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + fontFamily = FontFamily.Monospace + ) + ) + + Box( + modifier = Modifier + .padding(start = InnerInset) + .offset(x = with(density) { animatedDrag.toDp() }) + .size(KnobSize) + .scale(knobScale) + .clip(CircleShape) + .background(knobColor) + .align(Alignment.CenterStart), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = knobIconColor, + modifier = Modifier.size(18.dp) + ) + } + } + } +} diff --git a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/components/TerminalHeader.kt b/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/components/TerminalHeader.kt deleted file mode 100644 index 3afdb947..00000000 --- a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/components/TerminalHeader.kt +++ /dev/null @@ -1,85 +0,0 @@ -package io.github.bengidev.opencore.onboarding.presenter.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import io.github.bengidev.opencore.onboarding.domain.OnboardingPage -import io.github.bengidev.opencore.onboarding.theme.OnboardingTheme - -/** Card terminal rail — traffic dots, metric, breadcrumb labels. */ -@Composable -internal fun TerminalHeader( - page: OnboardingPage, - modifier: Modifier = Modifier -) { - val palette = OnboardingTheme.palette - - Row( - modifier = modifier - .fillMaxWidth() - .clip(RoundedCornerShape(6.dp)) - .background(palette.surfaceSubtle.copy(alpha = 0.5f)) - .border(1.dp, palette.lineSoft, RoundedCornerShape(6.dp)) - .padding(horizontal = 11.dp, vertical = 9.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Box( - modifier = Modifier - .size(7.dp) - .clip(CircleShape) - .background(palette.accentPrimary) - ) - Spacer(modifier = Modifier.width(5.dp)) - Box( - modifier = Modifier - .size(7.dp) - .clip(CircleShape) - .background(palette.textTertiary.copy(alpha = 0.42f)) - ) - Spacer(modifier = Modifier.width(5.dp)) - Box( - modifier = Modifier - .size(7.dp) - .clip(CircleShape) - .background(palette.textTertiary.copy(alpha = 0.24f)) - ) - } - - Spacer(modifier = Modifier.width(10.dp)) - - Text( - text = page.metric, - style = OnboardingTheme.typography.monoXs, - color = palette.textSecondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - Spacer(modifier = Modifier.weight(1f)) - - Text( - text = "AGENTS / PROMPTS / MODELS / REVIEW", - style = OnboardingTheme.typography.monoXs.copy(fontSize = 8.5.sp, lineHeight = 11.sp), - color = palette.textTertiary, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } -} diff --git a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/components/Textures.kt b/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/components/Textures.kt deleted file mode 100644 index 682bedd9..00000000 --- a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/components/Textures.kt +++ /dev/null @@ -1,73 +0,0 @@ -package io.github.bengidev.opencore.onboarding.presenter.components - -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.drawscope.DrawScope -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import io.github.bengidev.opencore.onboarding.theme.OnboardingTheme - -/** - * Diagonal hatch pattern background. - * Creates a fine technical texture that gives the UI a physical surface feel. - */ -@Composable -internal fun DiagonalHatchPattern( - modifier: Modifier = Modifier, - spacing: Dp = 10.dp, - strokeWidth: Dp = 1.dp, - color: Color = OnboardingTheme.palette.lineSoft.copy(alpha = if (OnboardingTheme.palette.isDark) 0.10f else 0.04f), - angle: Float = 45f -) { - Canvas(modifier = modifier.fillMaxSize()) { - val spacingPx = spacing.toPx() - val strokeWidthPx = strokeWidth.toPx() - val diagonal = size.width + size.height - val step = spacingPx - - for (i in -diagonal.toInt()..diagonal.toInt() step step.toInt()) { - drawLine( - color = color, - start = Offset(i.toFloat(), 0f), - end = Offset(i.toFloat() - diagonal, diagonal), - strokeWidth = strokeWidthPx - ) - } - } -} - -/** - * Pixel grid background with dot pattern. - * Creates a subtle technical grid texture. - */ -@Composable -internal fun PixelGridBackground( - modifier: Modifier = Modifier, - spacing: Dp = 15.dp, - dotSize: Dp = 1.dp, - color: Color = OnboardingTheme.palette.textTertiary.copy(alpha = if (OnboardingTheme.palette.isDark) 0.06f else 0.04f) -) { - Canvas(modifier = modifier.fillMaxSize()) { - val spacingPx = spacing.toPx() - val dotSizePx = dotSize.toPx() - val radius = dotSizePx / 2f - - var y = 0f - while (y < size.height) { - var x = 0f - while (x < size.width) { - drawCircle( - color = color, - radius = radius, - center = Offset(x, y) - ) - x += spacingPx - } - y += spacingPx - } - } -} diff --git a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/components/TopBar.kt b/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/components/TopBar.kt deleted file mode 100644 index 0e308996..00000000 --- a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/components/TopBar.kt +++ /dev/null @@ -1,98 +0,0 @@ -package io.github.bengidev.opencore.onboarding.presenter.components - -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.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import io.github.bengidev.opencore.onboarding.theme.AppTheme -import io.github.bengidev.opencore.onboarding.theme.LocalAppTheme -import io.github.bengidev.opencore.onboarding.theme.OnboardingTheme -import io.github.bengidev.opencore.ui.components.ThemeToggleButton - -/** Top bar — iOS OnboardingTopBarView. */ -@Composable -internal fun OnboardingTopBar( - currentPage: Int, - totalPages: Int, - onSkip: () -> Unit, - onThemeToggle: () -> Unit, - modifier: Modifier = Modifier -) { - val palette = OnboardingTheme.palette - val appTheme = LocalAppTheme.current - - Row( - modifier = modifier - .fillMaxWidth() - .height(44.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(9.dp) - ) { - ThemeToggleButton( - palette = palette, - isDark = palette.isDark, - isSystemMode = appTheme == AppTheme.System, - onClick = onThemeToggle - ) - - Column(verticalArrangement = Arrangement.spacedBy(1.dp)) { - Text( - text = "OPENCORE", - style = OnboardingTheme.typography.monoSm.copy( - fontFamily = androidx.compose.ui.text.font.FontFamily.SansSerif, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold - ), - color = palette.textPrimary - ) - Text( - text = "AI ASSISTANCE", - style = OnboardingTheme.typography.monoXs, - color = palette.textTertiary - ) - } - } - - Spacer(modifier = Modifier.weight(1f)) - - Text( - text = "PG.${String.format("%02d", currentPage + 1)} / ${String.format("%02d", totalPages)}", - style = OnboardingTheme.typography.monoSm, - color = palette.textSecondary, - maxLines = 1 - ) - - Spacer(modifier = Modifier.width(10.dp)) - - Text( - text = "SKIP", - style = OnboardingTheme.typography.monoSm.copy(fontSize = 11.sp), - color = palette.textSecondary, - modifier = Modifier - .clip(RoundedCornerShape(6.dp)) - .background(palette.surfaceSubtle.copy(alpha = 0.4f)) - .border(1.dp, palette.lineSoft, RoundedCornerShape(6.dp)) - .clickable(onClick = onSkip) - .padding(horizontal = 10.dp, vertical = 6.dp) - ) - } -} diff --git a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/cube/CubeRenderer.kt b/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/cube/CubeRenderer.kt new file mode 100644 index 00000000..d91f7455 --- /dev/null +++ b/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/cube/CubeRenderer.kt @@ -0,0 +1,333 @@ +package io.github.bengidev.opencore.onboarding.presenter.cube + +import androidx.compose.ui.geometry.Offset +import kotlin.math.cos +import kotlin.math.pow +import kotlin.math.sin +import kotlin.random.Random + +internal data class CubeVertex(val x: Float, val y: Float, val z: Float) + +internal data class CubeEdge(val start: Int, val end: Int) + +internal data class CubeFrame( + val projectedVertices: List, + val edges: List, + val dashedEdgeIndices: Set, + val vertexOpacities: List, + val edgeStrokeEnds: List +) + +internal class CubeRenderer( + private val random: Random = Random.Default +) { + private val vertices = listOf( + CubeVertex(-1f, -1f, -1f), CubeVertex(1f, -1f, -1f), + CubeVertex(1f, 1f, -1f), CubeVertex(-1f, 1f, -1f), + CubeVertex(-1f, -1f, 1f), CubeVertex(1f, -1f, 1f), + CubeVertex(1f, 1f, 1f), CubeVertex(-1f, 1f, 1f) + ) + + private val edges = listOf( + CubeEdge(0, 1), CubeEdge(1, 2), CubeEdge(2, 3), CubeEdge(3, 0), + CubeEdge(4, 5), CubeEdge(5, 6), CubeEdge(6, 7), CubeEdge(7, 4), + CubeEdge(0, 4), CubeEdge(1, 5), CubeEdge(2, 6), CubeEdge(3, 7) + ) + + private val dashedEdges = setOf(6, 7, 11) + + private enum class Phase { Construction, Morph } + + private var phase = Phase.Construction + private var isAppeared = false + private var isMorphPaused = false + private var reduceMotion = false + private var rotationProgress = 0f + private var rotationFromYaw: Float? = null + private var rotationFromPitch: Float? = null + private var rotationFromRoll: Float? = null + + private val baseYaw = 0.6f + private val basePitch = 0.52f + private val baseRoll = 0f + + private var morphFromYaw = baseYaw + private var morphFromPitch = basePitch + private var morphFromRoll = baseRoll + private var morphToYaw = baseYaw + private var morphToPitch = basePitch + private var morphToRoll = baseRoll + private var morphSegmentStartElapsed: Double? = null + private var morphSegmentDuration = 0.52 + private var lastElapsedSeconds: Double? = null + + private val headerIconYaw = (Math.PI / 4).toFloat() + private val headerIconPitch = kotlin.math.atan(1.0 / kotlin.math.sqrt(2.0)).toFloat() + private val headerIconRoll = 0f + + private val constructionDuration = 0.75 + private val vertexPhaseEnd = 0.35f + private val edgePhaseStart = 0.2f + private val morphOverlapStart = 0.72f + private val morphSegmentOverlap = 0.88f + + fun setAppeared(value: Boolean) { + if (isAppeared == value) return + isAppeared = value + if (value) { + resetMorphState() + } else { + resetMorphState() + } + } + + fun setReduceMotion(value: Boolean) { + if (reduceMotion == value) return + reduceMotion = value + if (value) { + resetMorphState() + } + } + + fun setMorphPaused(value: Boolean) { + isMorphPaused = value + } + + fun setRotationProgress(value: Float) { + val clamped = value.coerceIn(0f, 1f) + if (rotationProgress == clamped) return + + if (rotationProgress == 0f && clamped > 0f) { + val orientation = currentFreeOrientation(lastElapsedSeconds) + rotationFromYaw = orientation.first + rotationFromPitch = orientation.second + rotationFromRoll = orientation.third + phase = Phase.Morph + } + + rotationProgress = clamped + + if (clamped >= 1f) { + morphFromYaw = headerIconYaw + morphFromPitch = headerIconPitch + morphFromRoll = headerIconRoll + morphToYaw = headerIconYaw + morphToPitch = headerIconPitch + morphToRoll = headerIconRoll + morphSegmentStartElapsed = null + } + } + + fun tick(elapsedSeconds: Double, width: Float, height: Float): CubeFrame { + lastElapsedSeconds = elapsedSeconds + if (!isAppeared) { + return buildFrame(0f, null, width, height) + } + + if (reduceMotion) { + return buildFrame(1f, null, width, height) + } + + if (isMorphPaused) { + return buildFrame(1f, elapsedSeconds, width, height) + } + + if (rotationProgress > 0f) { + return buildFrame(1f, elapsedSeconds, width, height) + } + + val progress = (elapsedSeconds / constructionDuration).toFloat().coerceIn(0f, 1f) + + if (progress >= morphOverlapStart && phase == Phase.Construction) { + phase = Phase.Morph + beginMorphSegment(elapsedSeconds, immediate = true) + } else if (phase == Phase.Morph) { + advanceMorphIfNeeded(elapsedSeconds) + } + + return buildFrame(progress, elapsedSeconds, width, height) + } + + private fun resetMorphState() { + phase = Phase.Construction + morphFromYaw = baseYaw + morphFromPitch = basePitch + morphFromRoll = baseRoll + morphToYaw = baseYaw + morphToPitch = basePitch + morphToRoll = baseRoll + morphSegmentStartElapsed = null + morphSegmentDuration = 0.52 + rotationProgress = 0f + rotationFromYaw = null + rotationFromPitch = null + rotationFromRoll = null + lastElapsedSeconds = null + } + + private fun beginMorphSegment(elapsedSeconds: Double, immediate: Boolean = false) { + val (yaw, pitch, roll) = currentOrientation(elapsedSeconds) + morphFromYaw = yaw + morphFromPitch = pitch + morphFromRoll = roll + val target = randomMorphTarget(Triple(yaw, pitch, roll), preferNoticeableChange = immediate) + morphToYaw = target.first + morphToPitch = target.second + morphToRoll = target.third + morphSegmentDuration = if (immediate) { + random.nextDouble(0.34, 0.46) + } else { + random.nextDouble(0.38, 0.52) + } + morphSegmentStartElapsed = elapsedSeconds + } + + private fun advanceMorphIfNeeded(elapsedSeconds: Double) { + val segmentStart = morphSegmentStartElapsed + if (segmentStart == null) { + beginMorphSegment(elapsedSeconds) + return + } + val elapsed = elapsedSeconds - segmentStart + if (elapsed >= morphSegmentDuration * morphSegmentOverlap) { + beginMorphSegment(elapsedSeconds) + } + } + + private fun randomMorphTarget( + current: Triple?, + preferNoticeableChange: Boolean + ): Triple { + val minimumDelta = if (preferNoticeableChange) 0.1f else 0.045f + repeat(8) { + val candidate = Triple( + random.nextFloat() * 0.7f + 0.35f, + random.nextFloat() * 0.34f + 0.28f, + random.nextFloat() * 0.32f - 0.16f + ) + if (current == null) return candidate + val delta = kotlin.math.abs(candidate.first - current.first) + + kotlin.math.abs(candidate.second - current.second) + + kotlin.math.abs(candidate.third - current.third) + if (delta >= minimumDelta) return candidate + } + return Triple( + (current?.first ?: baseYaw) + 0.22f, + (current?.second ?: basePitch) + 0.12f, + (current?.third ?: baseRoll) + 0.08f + ) + } + + private fun easeOut(t: Float): Float { + val clamped = t.coerceIn(0f, 1f) + return 1f - (1f - clamped).pow(3) + } + + private fun easeInOut(t: Float): Float { + val clamped = t.coerceIn(0f, 1f) + return if (clamped < 0.5f) { + 4f * clamped * clamped * clamped + } else { + 1f - (-2f * clamped + 2f).pow(3) / 2f + } + } + + private fun currentFreeOrientation(elapsedSeconds: Double?): Triple { + if (phase != Phase.Morph || elapsedSeconds == null) { + return Triple(baseYaw, basePitch, baseRoll) + } + val segmentStart = morphSegmentStartElapsed ?: return Triple(baseYaw, basePitch, baseRoll) + val t = easeInOut(((elapsedSeconds - segmentStart) / morphSegmentDuration).toFloat()) + return Triple( + morphFromYaw + (morphToYaw - morphFromYaw) * t, + morphFromPitch + (morphToPitch - morphFromPitch) * t, + morphFromRoll + (morphToRoll - morphFromRoll) * t + ) + } + + private fun currentOrientation(elapsedSeconds: Double?): Triple { + if (rotationProgress > 0f) { + val fromYaw = rotationFromYaw ?: morphFromYaw + val fromPitch = rotationFromPitch ?: morphFromPitch + val fromRoll = rotationFromRoll ?: morphFromRoll + val t = rotationProgress + return Triple( + fromYaw + (headerIconYaw - fromYaw) * t, + fromPitch + (headerIconPitch - fromPitch) * t, + fromRoll + (headerIconRoll - fromRoll) * t + ) + } + return currentFreeOrientation(elapsedSeconds) + } + + private fun project( + vertex: CubeVertex, + center: Offset, + half: Float, + yaw: Float, + pitch: Float, + roll: Float + ): Offset { + val x1 = vertex.x * cos(yaw) + vertex.z * sin(yaw) + val z1 = -vertex.x * sin(yaw) + vertex.z * cos(yaw) + val y2 = vertex.y * cos(pitch) - z1 * sin(pitch) + val x3 = x1 * cos(roll) - y2 * sin(roll) + val y3 = x1 * sin(roll) + y2 * cos(roll) + return Offset(center.x + x3 * half, center.y + y3 * half) + } + + private fun constructionVertexOpacity(index: Int, progress: Float): Float { + val vertexProgress = (progress / vertexPhaseEnd).coerceAtMost(1f) + val delay = index * 0.025f + val normalizedDelay = delay / vertexPhaseEnd + val linear = ((vertexProgress - normalizedDelay) / (1f - normalizedDelay)).coerceIn(0f, 1f) + return easeOut(linear) + } + + private fun constructionEdgeStrokeEnd(index: Int, progress: Float): Float { + val edgeSpan = (1f - edgePhaseStart).coerceAtLeast(0.001f) + val edgeProgress = ((progress - edgePhaseStart) / edgeSpan).coerceIn(0f, 1f) + val linear = if (dashedEdges.contains(index)) { + ((edgeProgress - 0.35f) / 0.65f).coerceIn(0f, 1f) + } else { + val delay = index * 0.05f + ((edgeProgress - delay) / (1f - delay)).coerceIn(0f, 1f) + } + return easeOut(linear) + } + + private fun buildFrame(progress: Float, elapsedSeconds: Double?, width: Float, height: Float): CubeFrame { + val center = Offset(width / 2f, height / 2f) + val half = minOf(width, height) * 0.34f + val orientation = currentOrientation(elapsedSeconds) + val projected = vertices.map { vertex -> + project(vertex, center, half, orientation.first, orientation.second, orientation.third) + } + + val constructionProgress = if (phase == Phase.Morph && progress >= 1f) null else progress + + val vertexOpacities = projected.indices.map { index -> + if (constructionProgress != null) { + constructionVertexOpacity(index, constructionProgress) + } else { + 1f + } + } + + val edgeStrokeEnds = edges.indices.map { index -> + if (constructionProgress != null) { + constructionEdgeStrokeEnd(index, constructionProgress) + } else { + 1f + } + } + + return CubeFrame( + projectedVertices = projected, + edges = edges, + dashedEdgeIndices = dashedEdges, + vertexOpacities = vertexOpacities, + edgeStrokeEnds = edgeStrokeEnds + ) + } +} diff --git a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/cube/OnboardingCubeView.kt b/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/cube/OnboardingCubeView.kt new file mode 100644 index 00000000..e26ebdb3 --- /dev/null +++ b/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/cube/OnboardingCubeView.kt @@ -0,0 +1,138 @@ +package io.github.bengidev.opencore.onboarding.presenter.cube + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.unit.dp + +@Composable +internal fun OnboardingCubeView( + appeared: Boolean, + inkColor: Color, + morphPaused: Boolean, + rotationProgress: Float, + reduceMotion: Boolean, + modifier: Modifier = Modifier +) { + val renderer = remember { CubeRenderer() } + var frameTimeNanos by remember { mutableLongStateOf(0L) } + var animationEpochNanos by remember { mutableLongStateOf(0L) } + var hasAnimationEpoch by remember { mutableStateOf(false) } + val density = LocalDensity.current + val strokeWidthPx = with(density) { 2.dp.toPx() } + val vertexRadiusPx = with(density) { 3.5.dp.toPx() } + + SideEffect { + renderer.setAppeared(appeared) + renderer.setMorphPaused(morphPaused) + renderer.setRotationProgress(rotationProgress) + renderer.setReduceMotion(reduceMotion) + } + + LaunchedEffect(appeared, reduceMotion) { + if (!appeared) { + hasAnimationEpoch = false + frameTimeNanos = 0L + return@LaunchedEffect + } + if (reduceMotion) return@LaunchedEffect + while (true) { + withFrameNanos { frameTime -> + if (!hasAnimationEpoch) { + animationEpochNanos = frameTime + hasAnimationEpoch = true + } + frameTimeNanos = frameTime + } + } + } + + Canvas( + modifier = modifier + .fillMaxSize() + .clearAndSetSemantics { } + ) { + if (!appeared) return@Canvas + + val elapsedSeconds = if (reduceMotion || !hasAnimationEpoch) { + 0.0 + } else { + (frameTimeNanos - animationEpochNanos) / 1_000_000_000.0 + } + + val frame = renderer.tick(elapsedSeconds, size.width, size.height) + val strokeWidth = strokeWidthPx + val vertexRadius = vertexRadiusPx + val dashOn = with(density) { 4.dp.toPx() } + val dashOff = with(density) { 3.dp.toPx() } + val dashedPathEffect = PathEffect.dashPathEffect(floatArrayOf(dashOn, dashOff), 0f) + + frame.edges.forEachIndexed { index, edge -> + val start = frame.projectedVertices[edge.start] + val end = frame.projectedVertices[edge.end] + val strokeEnd = frame.edgeStrokeEnds[index].coerceIn(0f, 1f) + if (strokeEnd <= 0f) return@forEachIndexed + + val trimmedEnd = Offset( + start.x + (end.x - start.x) * strokeEnd, + start.y + (end.y - start.y) * strokeEnd + ) + + val path = if (strokeEnd >= 1f) { + Path().apply { + moveTo(start.x, start.y) + lineTo(end.x, end.y) + } + } else { + Path().apply { + moveTo(start.x, start.y) + lineTo(trimmedEnd.x, trimmedEnd.y) + } + } + + val pathEffect = if (frame.dashedEdgeIndices.contains(index)) { + dashedPathEffect + } else { + null + } + + drawPath( + path = path, + color = inkColor, + style = Stroke( + width = strokeWidth, + cap = StrokeCap.Round, + pathEffect = pathEffect + ) + ) + } + + frame.projectedVertices.forEachIndexed { index, point -> + val opacity = frame.vertexOpacities[index] + if (opacity <= 0f) return@forEachIndexed + val scale = 0.85f + 0.15f * opacity + drawCircle( + color = inkColor.copy(alpha = opacity), + radius = vertexRadius * scale, + center = point + ) + } + } +} diff --git a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/visuals/EncryptedPairingVisualView.kt b/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/visuals/EncryptedPairingVisualView.kt deleted file mode 100644 index f39a63b4..00000000 --- a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/visuals/EncryptedPairingVisualView.kt +++ /dev/null @@ -1,396 +0,0 @@ -package io.github.bengidev.opencore.onboarding.presenter.visuals - -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -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.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.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -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.filled.Link -import androidx.compose.material.icons.filled.LockOpen -import androidx.compose.material.icons.filled.Laptop -import androidx.compose.material.icons.filled.PhoneAndroid -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.Shield -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.scale -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.PathEffect -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -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 io.github.bengidev.opencore.onboarding.theme.OnboardingTheme - -private val PairingSpring = spring( - dampingRatio = 0.72f, - stiffness = Spring.StiffnessMedium -) - -/** Encrypted pairing demo — device nodes, dashed channel, traveling dot with glow. */ -@Composable -internal fun EncryptedPairingVisualView( - pairingConfirmed: Boolean, - onTogglePairing: () -> Unit, - onActionButtonClick: () -> Unit, - modifier: Modifier = Modifier, - appeared: Boolean = true -) { - val palette = OnboardingTheme.palette - val radius = OnboardingTheme.radius - val accent = if (pairingConfirmed) palette.accentPrimary else palette.warning - - val leftEntryOffset by animateFloatAsState( - targetValue = if (appeared) 0f else -24f, - animationSpec = PairingSpring, - label = "pairing_left_entry" - ) - val rightEntryOffset by animateFloatAsState( - targetValue = if (appeared) 0f else 24f, - animationSpec = PairingSpring, - label = "pairing_right_entry" - ) - val centerAlpha by animateFloatAsState( - targetValue = if (appeared) 1f else 0f, - animationSpec = tween(durationMillis = 420, delayMillis = 80), - label = "pairing_center_alpha" - ) - val dotTravelOffset by animateFloatAsState( - targetValue = if (pairingConfirmed) 56f else -56f, - animationSpec = PairingSpring, - label = "pairing_dot_travel" - ) - val shieldScale by animateFloatAsState( - targetValue = if (pairingConfirmed) 1f else 0.94f, - animationSpec = PairingSpring, - label = "pairing_shield_scale" - ) - - val pulseTransition = rememberInfiniteTransition(label = "pairing_pulse") - val dotPulseScale by pulseTransition.animateFloat( - initialValue = 1f, - targetValue = if (pairingConfirmed) 1.22f else 1f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 900, easing = androidx.compose.animation.core.FastOutSlowInEasing), - repeatMode = RepeatMode.Reverse - ), - label = "pairing_dot_pulse" - ) - val dotPulseAlpha by pulseTransition.animateFloat( - initialValue = 0.45f, - targetValue = if (pairingConfirmed) 0.85f else 0.45f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 900), - repeatMode = RepeatMode.Reverse - ), - label = "pairing_dot_glow_alpha" - ) - - Box( - modifier = modifier - .fillMaxSize() - .semantics { contentDescription = "End-to-end encrypted local-to-OpenCore pairing diagram" } - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.Center) - .padding(horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - DeviceNode( - icon = Icons.Filled.PhoneAndroid, - title = "LOCAL", - subtitle = "Local key", - active = pairingConfirmed, - modifier = Modifier.graphicsLayer { translationX = leftEntryOffset.dp.toPx() } - ) - Spacer(modifier = Modifier.weight(1f)) - DeviceNode( - icon = Icons.Filled.Laptop, - title = "OPENCORE", - subtitle = "AI chat lane", - active = pairingConfirmed, - modifier = Modifier.graphicsLayer { translationX = rightEntryOffset.dp.toPx() } - ) - } - - Column( - modifier = Modifier - .align(Alignment.Center) - .graphicsLayer { - alpha = centerAlpha - scaleX = 0.92f + (centerAlpha * 0.08f) - scaleY = 0.92f + (centerAlpha * 0.08f) - }, - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(82.dp), - contentAlignment = Alignment.Center - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.Center) - .padding(horizontal = 54.dp), - verticalAlignment = Alignment.CenterVertically - ) { - DashedConnectionSegment( - modifier = Modifier - .weight(1f) - .height(3.dp), - color = palette.lineSoft.copy(alpha = 0.8f), - animated = pairingConfirmed - ) - Spacer(modifier = Modifier.width(82.dp)) - DashedConnectionSegment( - modifier = Modifier - .weight(1f) - .height(3.dp), - color = palette.lineSoft.copy(alpha = 0.8f), - animated = pairingConfirmed - ) - } - - Box( - modifier = Modifier - .offset(x = dotTravelOffset.dp) - .scale(dotPulseScale) - .shadow( - elevation = if (pairingConfirmed) 10.dp else 4.dp, - shape = CircleShape, - ambientColor = palette.accentPrimary.copy(alpha = dotPulseAlpha), - spotColor = palette.accentPrimary.copy(alpha = dotPulseAlpha) - ) - .size(9.dp) - .clip(CircleShape) - .background(palette.accentPrimary) - ) - - Box( - modifier = Modifier - .scale(shieldScale) - .size(82.dp) - .shadow( - elevation = if (pairingConfirmed) 6.dp else 2.dp, - shape = RoundedCornerShape(radius.sm), - ambientColor = accent.copy(alpha = 0.18f), - spotColor = accent.copy(alpha = 0.24f) - ) - .clip(RoundedCornerShape(radius.sm)) - .background(palette.surfaceRaised) - .border(1.dp, palette.lineSoft, RoundedCornerShape(radius.sm)) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = onTogglePairing - ), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = if (pairingConfirmed) Icons.Filled.Shield else Icons.Filled.LockOpen, - contentDescription = null, - tint = accent, - modifier = Modifier.size(30.dp) - ) - } - } - - PairingActionButton( - pairingConfirmed = pairingConfirmed, - accent = accent, - onClick = onActionButtonClick - ) - } - } -} - -@Composable -private fun DashedConnectionSegment( - color: Color, - animated: Boolean, - modifier: Modifier = Modifier -) { - val phaseTransition = rememberInfiniteTransition(label = "dash_phase") - val dashPhase by phaseTransition.animateFloat( - initialValue = 0f, - targetValue = if (animated) 24f else 0f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 1400, easing = androidx.compose.animation.core.LinearEasing), - repeatMode = RepeatMode.Restart - ), - label = "dash_phase_offset" - ) - - Canvas(modifier = modifier) { - val centerY = size.height / 2f - drawLine( - color = color, - start = Offset(0f, centerY), - end = Offset(size.width, centerY), - strokeWidth = 1.dp.toPx(), - pathEffect = PathEffect.dashPathEffect( - intervals = floatArrayOf(5.dp.toPx(), 7.dp.toPx()), - phase = dashPhase - ) - ) - } -} - -@Composable -private fun DeviceNode( - icon: ImageVector, - title: String, - subtitle: String, - active: Boolean, - modifier: Modifier = Modifier -) { - val palette = OnboardingTheme.palette - val radius = OnboardingTheme.radius - - val borderColor by animateColorAsState( - targetValue = if (active) palette.accentPrimary.copy(alpha = 0.52f) else palette.lineSoft, - animationSpec = tween(320), - label = "device_border" - ) - val iconTint by animateColorAsState( - targetValue = if (active) palette.textPrimary else palette.textTertiary, - animationSpec = tween(320), - label = "device_icon" - ) - val cardScale by animateFloatAsState( - targetValue = if (active) 1f else 0.97f, - animationSpec = PairingSpring, - label = "device_scale" - ) - - Column( - modifier = modifier - .width(108.dp) - .scale(cardScale), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(9.dp) - ) { - Box( - modifier = Modifier - .size(width = 76.dp, height = 92.dp) - .shadow( - elevation = if (active) 4.dp else 0.dp, - shape = RoundedCornerShape(radius.sm), - ambientColor = palette.accentPrimary.copy(alpha = 0.12f), - spotColor = palette.accentPrimary.copy(alpha = 0.16f) - ) - .clip(RoundedCornerShape(radius.sm)) - .background(palette.surfaceSubtle.copy(alpha = if (active) 0.62f else 0.5f)) - .border(1.dp, borderColor, RoundedCornerShape(radius.sm)), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = iconTint, - modifier = Modifier.size(32.dp) - ) - } - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(3.dp) - ) { - Text( - text = title, - style = OnboardingTheme.typography.monoXs, - color = palette.textPrimary, - textAlign = TextAlign.Center, - maxLines = 1 - ) - Text( - text = subtitle, - style = OnboardingTheme.typography.monoXs.copy( - fontSize = 8.5.sp, - fontWeight = FontWeight.Normal, - letterSpacing = 0.sp - ), - color = palette.textTertiary, - textAlign = TextAlign.Center, - maxLines = 1 - ) - } - } -} - -@Composable -private fun PairingActionButton( - pairingConfirmed: Boolean, - accent: Color, - onClick: () -> Unit -) { - val scale by animateFloatAsState( - targetValue = 1f, - animationSpec = PairingSpring, - label = "cta_scale" - ) - - Row( - modifier = Modifier - .scale(scale) - .clip(RoundedCornerShape(6.dp)) - .background(accent.copy(alpha = 0.12f)) - .border(1.dp, accent.copy(alpha = 0.32f), RoundedCornerShape(6.dp)) - .clickable(onClick = onClick) - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(7.dp) - ) { - Icon( - imageVector = if (pairingConfirmed) Icons.Filled.Refresh else Icons.Filled.Link, - contentDescription = null, - tint = accent, - modifier = Modifier.size(14.dp) - ) - Text( - text = if (pairingConfirmed) "ROTATE KEY" else "PAIR DEVICE", - style = OnboardingTheme.typography.monoSm, - color = accent - ) - } -} diff --git a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/visuals/IdeaStudioVisualView.kt b/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/visuals/IdeaStudioVisualView.kt deleted file mode 100644 index 76aaadeb..00000000 --- a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/visuals/IdeaStudioVisualView.kt +++ /dev/null @@ -1,210 +0,0 @@ -package io.github.bengidev.opencore.onboarding.presenter.visuals - -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.border -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.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -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.graphicsLayer -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 io.github.bengidev.opencore.onboarding.domain.OnboardingPromptOption -import io.github.bengidev.opencore.onboarding.presenter.components.MonoChipButton -import io.github.bengidev.opencore.onboarding.theme.OnboardingTheme -import kotlinx.coroutines.delay - -/** iOS-matched idea studio: chips, then one bordered panel with prompt + response skeleton lines. */ -@Composable -internal fun IdeaStudioVisualView( - selectedPromptIndex: Int, - onPromptSelected: (Int) -> Unit, - modifier: Modifier = Modifier, - reduceMotion: Boolean = false, - appeared: Boolean = true -) { - val palette = OnboardingTheme.palette - val radius = OnboardingTheme.radius - - val selectedPrompt = OnboardingPromptOption.samples.getOrElse(selectedPromptIndex) { - OnboardingPromptOption.samples.first() - } - var typedLength by remember(selectedPrompt) { mutableIntStateOf(0) } - var isTypingComplete by remember(selectedPrompt) { mutableStateOf(false) } - - val fullText = selectedPrompt.prompt - - val infiniteTransition = rememberInfiniteTransition(label = "cursorBlink") - val cursorAlpha by infiniteTransition.animateFloat( - initialValue = 1f, - targetValue = 0f, - animationSpec = infiniteRepeatable( - animation = tween(520, easing = LinearEasing), - repeatMode = RepeatMode.Reverse - ), - label = "cursorAlpha" - ) - - LaunchedEffect(selectedPrompt) { - isTypingComplete = false - typedLength = 0 - if (reduceMotion) { - typedLength = fullText.length - isTypingComplete = true - } else { - fullText.forEachIndexed { index, _ -> - delay(16L) - typedLength = index + 1 - } - isTypingComplete = true - } - } - - val visibleText = fullText.substring(0, typedLength) - val cursorOpacity = if (!isTypingComplete) cursorAlpha else 0.34f - - Column( - modifier = modifier - .fillMaxWidth() - .fillMaxHeight(), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(7.dp), - modifier = Modifier.fillMaxWidth() - ) { - OnboardingPromptOption.samples.forEachIndexed { index, option -> - MonoChipButton( - text = option.label, - selected = index == selectedPromptIndex, - onClick = { onPromptSelected(index) }, - modifier = Modifier.weight(1f) - ) - } - } - - Column( - modifier = Modifier - .weight(1f) - .fillMaxWidth() - .clip(RoundedCornerShape(radius.sm)) - .background( - palette.surfaceBase.copy(alpha = if (palette.isDark) 0.5f else 0.15f) - ) - .border(1.dp, palette.lineSoft, RoundedCornerShape(radius.sm)) - .padding(13.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - Row( - verticalAlignment = Alignment.Top, - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = ">", - style = OnboardingTheme.typography.monoSm.copy(fontSize = 13.sp), - fontWeight = FontWeight.Bold, - color = palette.accentPrimary, - modifier = Modifier.padding(end = 8.dp) - ) - Text( - text = visibleText, - style = OnboardingTheme.typography.monoSm, - fontWeight = FontWeight.Medium, - color = palette.textPrimary, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) - Box( - modifier = Modifier - .padding(start = 2.dp) - .width(6.dp) - .height(15.dp) - .background(palette.textPrimary.copy(alpha = cursorOpacity)) - ) - } - - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - SkeletonLine(width = 0.84f, delayMillis = 0, active = appeared, reduceMotion = reduceMotion) - SkeletonLine(width = 0.62f, delayMillis = 70, active = appeared, reduceMotion = reduceMotion) - SkeletonLine(width = 0.74f, delayMillis = 140, active = appeared, reduceMotion = reduceMotion) - } - } - } -} - -@Composable -private fun SkeletonLine( - width: Float, - delayMillis: Int, - active: Boolean, - reduceMotion: Boolean -) { - val palette = OnboardingTheme.palette - val alpha = remember { Animatable(0f) } - val offsetX = remember { Animatable(-12f) } - - LaunchedEffect(active, reduceMotion) { - if (!active) { - alpha.snapTo(0f) - offsetX.snapTo(-12f) - return@LaunchedEffect - } - if (reduceMotion) { - alpha.snapTo(1f) - offsetX.snapTo(0f) - } else { - delay(delayMillis.toLong()) - alpha.animateTo(1f, tween(340, easing = FastOutSlowInEasing)) - offsetX.animateTo(0f, tween(340, easing = FastOutSlowInEasing)) - } - } - - Box( - modifier = Modifier - .fillMaxWidth(width) - .height(8.dp) - .graphicsLayer { - this.alpha = alpha.value - translationX = offsetX.value - } - .clip(RoundedCornerShape(4.dp)) - .background( - Brush.horizontalGradient( - colors = listOf( - palette.accentPrimary.copy(alpha = 0.18f), - palette.textPrimary.copy(alpha = 0.20f), - palette.textTertiary.copy(alpha = 0.12f) - ) - ) - ) - ) -} diff --git a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/visuals/PromptQueueVisualView.kt b/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/visuals/PromptQueueVisualView.kt deleted file mode 100644 index 5588310d..00000000 --- a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/visuals/PromptQueueVisualView.kt +++ /dev/null @@ -1,300 +0,0 @@ -package io.github.bengidev.opencore.onboarding.presenter.visuals - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.EaseOutCubic -import androidx.compose.animation.core.FastOutSlowInEasing -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.spring -import androidx.compose.animation.core.tween -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.animation.slideInVertically -import androidx.compose.foundation.background -import androidx.compose.foundation.border -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.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -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.List -import androidx.compose.material.icons.filled.HourglassTop -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.key -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.draw.scale -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.dp -import io.github.bengidev.opencore.onboarding.domain.OnboardingQueueItem -import io.github.bengidev.opencore.onboarding.theme.OnboardingTheme -import kotlinx.coroutines.delay - -private val QueueEnterSpring = spring( - dampingRatio = 0.86f, - stiffness = 220f -) - -private val QueueContentSizeSpec = tween(durationMillis = 420, easing = EaseOutCubic) - -private val queueRowEnter = fadeIn(tween(420, easing = EaseOutCubic)) + - expandVertically(tween(460, easing = EaseOutCubic)) + - slideInVertically( - initialOffsetY = { fullHeight -> fullHeight / 3 }, - animationSpec = tween(460, easing = EaseOutCubic) - ) - -private val queueRowExit = fadeOut(tween(280, easing = FastOutSlowInEasing)) + - shrinkVertically(tween(320, easing = FastOutSlowInEasing)) - -/** Prompt queue demo — staggered rows, running pulse, animated timeline. */ -@Composable -internal fun PromptQueueVisualView( - queuedPromptCount: Int, - modifier: Modifier = Modifier, - appeared: Boolean = true -) { - val visibleCount = queuedPromptCount.coerceIn(1, OnboardingQueueItem.samples.size) - - Column( - modifier = modifier - .fillMaxWidth() - .animateContentSize(animationSpec = QueueContentSizeSpec), - verticalArrangement = Arrangement.spacedBy(9.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - OnboardingQueueItem.samples.forEachIndexed { index, item -> - key(item.id) { - AnimatedVisibility( - visible = appeared && index < visibleCount, - enter = queueRowEnter, - exit = queueRowExit - ) { - QueueRow( - item = item, - index = index, - isLast = index == visibleCount - 1, - rowEnterDelayMs = index * 72L - ) - } - } - } - Spacer(modifier = Modifier.height(16.dp)) - } -} - -@Composable -private fun QueueRow( - item: OnboardingQueueItem, - index: Int, - isLast: Boolean, - rowEnterDelayMs: Long, - modifier: Modifier = Modifier -) { - val palette = OnboardingTheme.palette - val radius = OnboardingTheme.radius - val isRunning = item.status == OnboardingQueueItem.Status.RUNNING - - val statusColor = when (item.status) { - OnboardingQueueItem.Status.RUNNING -> palette.accentPrimary - OnboardingQueueItem.Status.NEXT -> palette.warning - OnboardingQueueItem.Status.QUEUED -> palette.textSecondary - OnboardingQueueItem.Status.READY -> palette.success - } - - val contentAlpha = remember(item.id) { Animatable(0f) } - val contentOffset = remember(item.id) { Animatable(14f) } - - LaunchedEffect(item.id, rowEnterDelayMs) { - contentAlpha.snapTo(0f) - contentOffset.snapTo(14f) - delay(rowEnterDelayMs) - contentAlpha.animateTo(1f, QueueEnterSpring) - contentOffset.animateTo(0f, QueueEnterSpring) - } - - val runningBorderAlpha by animateFloatAsState( - targetValue = if (isRunning) 0.34f else 0f, - animationSpec = tween(380, easing = FastOutSlowInEasing), - label = "running_border_alpha" - ) - val borderColor by animateColorAsState( - targetValue = when { - isRunning -> palette.accentPrimary.copy(alpha = runningBorderAlpha.coerceAtLeast(0.34f)) - index == 0 -> palette.accentPrimary.copy(alpha = 0.34f) - else -> palette.lineSoft - }, - animationSpec = tween(420, easing = FastOutSlowInEasing), - label = "queue_row_border" - ) - - val pulseTransition = rememberInfiniteTransition(label = "queue_running_pulse") - val runningDotScale by pulseTransition.animateFloat( - initialValue = 1f, - targetValue = if (isRunning) 1.35f else 1f, - animationSpec = infiniteRepeatable( - animation = tween(720, easing = FastOutSlowInEasing), - repeatMode = RepeatMode.Reverse - ), - label = "running_dot_scale" - ) - val runningDotAlpha by pulseTransition.animateFloat( - initialValue = 1f, - targetValue = if (isRunning) 0.55f else 1f, - animationSpec = infiniteRepeatable( - animation = tween(720), - repeatMode = RepeatMode.Reverse - ), - label = "running_dot_alpha" - ) - val runningCardAlpha by pulseTransition.animateFloat( - initialValue = 0.5f, - targetValue = if (isRunning) 0.62f else 0.3f, - animationSpec = infiniteRepeatable( - animation = tween(1100, easing = FastOutSlowInEasing), - repeatMode = RepeatMode.Reverse - ), - label = "running_card_alpha" - ) - val hourglassRotation by pulseTransition.animateFloat( - initialValue = 0f, - targetValue = if (isRunning) 180f else 0f, - animationSpec = infiniteRepeatable( - animation = tween(1600, easing = LinearEasing), - repeatMode = RepeatMode.Restart - ), - label = "hourglass_rotation" - ) - val connectorProgress by pulseTransition.animateFloat( - initialValue = 0f, - targetValue = if (isRunning && !isLast) 1f else 0f, - animationSpec = infiniteRepeatable( - animation = tween(900, easing = LinearEasing), - repeatMode = RepeatMode.Restart - ), - label = "connector_progress" - ) - - Row( - modifier = modifier - .fillMaxWidth() - .graphicsLayer { - alpha = contentAlpha.value - translationY = contentOffset.value - } - .clip(RoundedCornerShape(radius.sm)) - .background( - palette.surfaceSubtle.copy( - alpha = if (isRunning) runningCardAlpha else if (index == 0) 0.5f else 0.3f - ) - ) - .border( - width = 1.dp, - color = borderColor, - shape = RoundedCornerShape(radius.sm) - ) - .padding(horizontal = 12.dp, vertical = 9.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { - Column( - modifier = Modifier.width(12.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Box( - modifier = Modifier - .scale(runningDotScale) - .size(8.dp) - .clip(CircleShape) - .background(statusColor.copy(alpha = runningDotAlpha.coerceIn(0.45f, 1f))) - ) - if (!isLast) { - Box( - modifier = Modifier - .width(1.dp) - .height(20.dp) - .background(palette.lineSoft.copy(alpha = 0.72f)) - ) { - if (isRunning) { - Box( - modifier = Modifier - .width(1.dp) - .height(20.dp * connectorProgress) - .align(Alignment.TopCenter) - .background(palette.accentPrimary.copy(alpha = 0.55f)) - ) - } - } - } - } - - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(3.dp) - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = item.status.name, - style = OnboardingTheme.typography.monoXs, - fontWeight = FontWeight.SemiBold, - color = statusColor - ) - Text( - text = item.title, - style = OnboardingTheme.typography.monoSm, - fontWeight = FontWeight.SemiBold, - color = palette.textPrimary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false) - ) - } - Text( - text = item.detail, - style = OnboardingTheme.typography.monoXs, - color = palette.textTertiary, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - - Icon( - imageVector = if (isRunning) Icons.Filled.HourglassTop else Icons.AutoMirrored.Filled.List, - contentDescription = item.status.name, - tint = if (isRunning) palette.accentPrimary else palette.textTertiary, - modifier = Modifier - .size(16.dp) - .rotate(if (isRunning) hourglassRotation else 0f) - ) - } -} diff --git a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/visuals/ReasoningControlVisualView.kt b/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/visuals/ReasoningControlVisualView.kt deleted file mode 100644 index c645f6a8..00000000 --- a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/visuals/ReasoningControlVisualView.kt +++ /dev/null @@ -1,277 +0,0 @@ -package io.github.bengidev.opencore.onboarding.presenter.visuals - -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.spring -import androidx.compose.foundation.Canvas -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.fillMaxHeight -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.shape.RoundedCornerShape -import androidx.compose.material3.Text -import io.github.bengidev.opencore.onboarding.presenter.components.ReasoningLevelSlider -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.TransformOrigin -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import io.github.bengidev.opencore.onboarding.theme.OnboardingTheme -import kotlin.math.abs -import kotlin.math.roundToInt -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - -/** Default reasoning level — matches iOS demo (BALANCED). */ -const val DefaultReasoningLevel = 0.62f - -/** iOS-matched reasoning control: ring, slider, presets, bar chart. */ -@Composable -internal fun ReasoningControlVisualView( - reasoningLevel: Float, - onReasoningLevelChanged: (Float) -> Unit, - modifier: Modifier = Modifier, - appeared: Boolean = true -) { - val palette = OnboardingTheme.palette - - val levelName = when { - reasoningLevel < 0.38f -> "FAST ANSWER" - reasoningLevel < 0.76f -> "BALANCED PLAN" - else -> "DEEP REASONING" - } - val percentage = (reasoningLevel * 100).roundToInt() - - Column( - modifier = modifier - .fillMaxWidth() - .fillMaxHeight() - .padding(horizontal = 4.dp), - verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - ReasoningRing( - value = reasoningLevel, - label = "$percentage%", - modifier = Modifier.size(76.dp) - ) - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = levelName, - style = OnboardingTheme.typography.monoSm, - fontWeight = FontWeight.SemiBold, - color = palette.accentPrimary - ) - Text( - text = "Set thinking before run.", - style = OnboardingTheme.typography.monoXs, - color = palette.textTertiary, - maxLines = 2 - ) - } - } - - ReasoningLevelSlider( - value = reasoningLevel, - onValueChange = onReasoningLevelChanged, - modifier = Modifier.fillMaxWidth() - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(7.dp) - ) { - PresetButton("FAST", 0.22f, reasoningLevel, { onReasoningLevelChanged(0.22f) }, Modifier.weight(1f)) - PresetButton("BALANCED", 0.62f, reasoningLevel, { onReasoningLevelChanged(0.62f) }, Modifier.weight(1f)) - PresetButton("DEEP", 0.90f, reasoningLevel, { onReasoningLevelChanged(0.90f) }, Modifier.weight(1f)) - } - - ComputeBudgetChart( - reasoningLevel = reasoningLevel, - appeared = appeared, - modifier = Modifier - .fillMaxWidth() - .height(44.dp) - ) - } -} - -@Composable -private fun ReasoningRing(value: Float, label: String, modifier: Modifier = Modifier) { - val palette = OnboardingTheme.palette - val animatedValue by animateFloatAsState( - targetValue = value, - animationSpec = spring( - dampingRatio = 0.74f, - stiffness = Spring.StiffnessMediumLow - ), - label = "reasoning_ring" - ) - - Box(modifier = modifier, contentAlignment = Alignment.Center) { - Canvas(modifier = Modifier.matchParentSize()) { - val progressStroke = 5.dp.toPx() - val trackStroke = 1.dp.toPx() - val radius = size.minDimension / 2f - progressStroke / 2f - drawCircle( - color = palette.lineSoft.copy(alpha = 0.75f), - radius = radius, - style = Stroke(trackStroke) - ) - drawArc( - color = palette.accentPrimary, - startAngle = -90f, - sweepAngle = 360f * animatedValue, - useCenter = false, - style = Stroke(progressStroke, cap = StrokeCap.Round) - ) - } - Text( - text = label, - style = OnboardingTheme.typography.monoSm.copy(fontSize = 17.sp), - fontWeight = FontWeight.SemiBold, - color = palette.textPrimary - ) - } -} - -@Composable -private fun PresetButton( - text: String, - value: Float, - currentValue: Float, - onClick: () -> Unit, - modifier: Modifier = Modifier -) { - val palette = OnboardingTheme.palette - val isSelected = abs(currentValue - value) < 0.08f - - Box( - modifier = modifier - .clip(RoundedCornerShape(6.dp)) - .background(if (isSelected) palette.controlStrong else palette.surfaceSubtle.copy(alpha = 0.4f)) - .border( - width = 1.dp, - color = if (isSelected) palette.controlStrong.copy(alpha = 0.3f) else palette.lineSoft, - shape = RoundedCornerShape(6.dp) - ) - .clickable(onClick = onClick) - .padding(vertical = 8.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = text, - style = OnboardingTheme.typography.monoXs, - color = if (isSelected) palette.controlStrongText else palette.textSecondary - ) - } -} - -@Composable -private fun ComputeBudgetChart( - reasoningLevel: Float, - appeared: Boolean, - modifier: Modifier = Modifier -) { - val barHeights = listOf(12.dp, 16.dp, 20.dp, 24.dp, 28.dp, 32.dp, 36.dp, 40.dp) - - Row( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(7.dp, Alignment.CenterHorizontally), - verticalAlignment = Alignment.Bottom - ) { - barHeights.forEachIndexed { index, height -> - val normalizedIndex = (index + 1) / 8f - val isActive = normalizedIndex <= reasoningLevel - BudgetBar( - height = height, - isActive = isActive, - index = index, - appeared = appeared, - modifier = Modifier.weight(1f) - ) - } - } -} - -@Composable -private fun BudgetBar( - height: Dp, - isActive: Boolean, - index: Int, - appeared: Boolean, - modifier: Modifier = Modifier -) { - val palette = OnboardingTheme.palette - val barScaleY = remember { Animatable(0.35f) } - val barAlpha = remember { Animatable(0f) } - - LaunchedEffect(appeared) { - if (appeared) { - delay(index * 35L) - launch { - barScaleY.animateTo( - targetValue = 1f, - animationSpec = spring( - dampingRatio = 0.8f, - stiffness = Spring.StiffnessMedium - ) - ) - } - launch { - barAlpha.animateTo( - targetValue = 1f, - animationSpec = spring( - dampingRatio = 0.8f, - stiffness = Spring.StiffnessMedium - ) - ) - } - } else { - barScaleY.snapTo(0.35f) - barAlpha.snapTo(0f) - } - } - - Box( - modifier = modifier - .height(height) - .graphicsLayer { - this.alpha = barAlpha.value - scaleY = barScaleY.value - transformOrigin = TransformOrigin(0.5f, 1f) - } - .clip(RoundedCornerShape(3.dp)) - .background( - if (isActive) palette.accentPrimary - else palette.textTertiary.copy(alpha = 0.22f) - ) - ) -} diff --git a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/visuals/WorkspaceReadyVisualView.kt b/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/visuals/WorkspaceReadyVisualView.kt deleted file mode 100644 index a74d6759..00000000 --- a/app/src/main/java/io/github/bengidev/opencore/onboarding/presenter/visuals/WorkspaceReadyVisualView.kt +++ /dev/null @@ -1,204 +0,0 @@ -package io.github.bengidev.opencore.onboarding.presenter.visuals - -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.spring -import androidx.compose.foundation.background -import androidx.compose.foundation.border -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.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -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.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import io.github.bengidev.opencore.onboarding.domain.OnboardingFeatureHighlight -import io.github.bengidev.opencore.onboarding.domain.OnboardingPage -import io.github.bengidev.opencore.ui.components.ScaleToFitText -import io.github.bengidev.opencore.onboarding.theme.OnboardingTheme -import kotlinx.coroutines.delay - -/** iOS OnboardingWorkspaceReadyVisualView — centered hero, capsule tags. */ -@Composable -internal fun WorkspaceReadyVisualView( - page: OnboardingPage, - modifier: Modifier = Modifier, - appeared: Boolean = true -) { - val palette = OnboardingTheme.palette - val typography = OnboardingTheme.typography - - var chipVisible by remember(page.id) { mutableStateOf(false) } - var headlineVisible by remember(page.id) { mutableStateOf(false) } - var bodyVisible by remember(page.id) { mutableStateOf(false) } - var tagsVisible by remember(page.id) { mutableStateOf(false) } - - LaunchedEffect(page.id, appeared) { - if (!appeared) { - chipVisible = false - headlineVisible = false - bodyVisible = false - tagsVisible = false - return@LaunchedEffect - } - chipVisible = false - headlineVisible = false - bodyVisible = false - tagsVisible = false - delay(50) - chipVisible = true - delay(70) - headlineVisible = true - delay(80) - bodyVisible = true - delay(80) - tagsVisible = true - } - - Column( - modifier = modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer(modifier = Modifier.weight(1f)) - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(28.dp) - ) { - AnimatedFadeSlide(visible = chipVisible, slideOffset = 10f) { - Row( - modifier = Modifier - .clip(RoundedCornerShape(999.dp)) - .background(palette.surfaceSubtle.copy(alpha = 0.5f)) - .border(1.dp, palette.lineSoft, RoundedCornerShape(999.dp)) - .padding(horizontal = 14.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Box( - modifier = Modifier - .size(7.dp) - .clip(CircleShape) - .background(palette.accentPrimary) - ) - Icon( - imageVector = Icons.Filled.Settings, - contentDescription = null, - tint = palette.textTertiary, - modifier = Modifier.size(14.dp) - ) - Text( - text = "WORKSPACE READY", - style = typography.monoSm.copy(fontSize = 13.sp), - color = palette.textTertiary - ) - } - } - - AnimatedFadeSlide(visible = headlineVisible, slideOffset = 14f) { - ScaleToFitText( - text = page.headline, - style = typography.displayXl, - color = palette.textPrimary, - textAlign = TextAlign.Center, - maxLines = 1, - minFontSize = 40.sp, - modifier = Modifier.fillMaxWidth() - ) - } - - AnimatedFadeSlide(visible = bodyVisible, slideOffset = 10f) { - ScaleToFitText( - text = page.body, - style = typography.bodyLg.copy(lineHeight = 28.sp), - color = palette.textSecondary, - textAlign = TextAlign.Center, - maxLines = 4, - minFontSize = 17.sp, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp) - ) - } - - AnimatedFadeSlide(visible = tagsVisible, slideOffset = 8f) { - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - page.highlights.forEach { highlight -> - FeatureTag(highlight = highlight) - } - } - } - } - - Spacer(modifier = Modifier.weight(1f)) - } -} - -@Composable -private fun FeatureTag(highlight: OnboardingFeatureHighlight) { - val palette = OnboardingTheme.palette - - ScaleToFitText( - text = highlight.title.uppercase(), - style = OnboardingTheme.typography.monoSm.copy(fontSize = 12.sp), - color = palette.textSecondary, - maxLines = 1, - minFontSize = 9.sp, - modifier = Modifier - .clip(RoundedCornerShape(999.dp)) - .background(palette.surfaceSubtle.copy(alpha = 0.3f)) - .border(1.dp, palette.lineSoft, RoundedCornerShape(999.dp)) - .padding(horizontal = 10.dp, vertical = 7.dp) - ) -} - -@Composable -private fun AnimatedFadeSlide( - visible: Boolean, - slideOffset: Float, - content: @Composable () -> Unit -) { - val alpha by animateFloatAsState( - targetValue = if (visible) 1f else 0f, - animationSpec = spring(Spring.DampingRatioNoBouncy, Spring.StiffnessLow), - label = "fade_slide_alpha" - ) - val offset by animateFloatAsState( - targetValue = if (visible) 0f else slideOffset, - animationSpec = spring(Spring.DampingRatioNoBouncy, Spring.StiffnessLow), - label = "fade_slide_offset" - ) - - Box( - modifier = Modifier.graphicsLayer { - this.alpha = alpha - translationY = offset - } - ) { - content() - } -} diff --git a/app/src/main/res/drawable/onboarding_feature_neural_core.png b/app/src/main/res/drawable/onboarding_feature_neural_core.png new file mode 100644 index 00000000..9711eb11 Binary files /dev/null and b/app/src/main/res/drawable/onboarding_feature_neural_core.png differ diff --git a/app/src/main/res/drawable/onboarding_feature_spatial_canvas.png b/app/src/main/res/drawable/onboarding_feature_spatial_canvas.png new file mode 100644 index 00000000..4331d5e7 Binary files /dev/null and b/app/src/main/res/drawable/onboarding_feature_spatial_canvas.png differ diff --git a/app/src/main/res/drawable/onboarding_feature_vault.png b/app/src/main/res/drawable/onboarding_feature_vault.png new file mode 100644 index 00000000..74fa4808 Binary files /dev/null and b/app/src/main/res/drawable/onboarding_feature_vault.png differ diff --git a/app/src/main/res/drawable/onboarding_feature_workflows.png b/app/src/main/res/drawable/onboarding_feature_workflows.png new file mode 100644 index 00000000..b7c0ed92 Binary files /dev/null and b/app/src/main/res/drawable/onboarding_feature_workflows.png differ diff --git a/app/src/test/java/io/github/bengidev/opencore/onboarding/application/OnboardingReducerTest.kt b/app/src/test/java/io/github/bengidev/opencore/onboarding/application/OnboardingReducerTest.kt index f629f2aa..b5536ed0 100644 --- a/app/src/test/java/io/github/bengidev/opencore/onboarding/application/OnboardingReducerTest.kt +++ b/app/src/test/java/io/github/bengidev/opencore/onboarding/application/OnboardingReducerTest.kt @@ -1,136 +1,32 @@ package io.github.bengidev.opencore.onboarding.application -import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test -/** - * OpenCoreOnboardingFeatureTests. - * Exercises the Command pattern via [OnboardingReducer]. - */ class OnboardingReducerTest { @Test - fun initialState_isPageZeroNotFinished() { + fun initialState_isNotFinished() { val state = OnboardingState() - assertEquals(0, state.currentPage) assertFalse(state.isFinished) - assertEquals(5, state.totalPages) - assertFalse(state.isLastPage) } @Test - fun nextButtonTapped_advancesPage() { + fun finishRequested_marksFinished() { val result = OnboardingReducer.reduce( OnboardingState(), - OnboardingIntent.NextButtonTapped + OnboardingIntent.FinishRequested ) - assertEquals(1, result.currentPage) - } - - @Test - fun previousButtonTapped_goesBack() { - val result = OnboardingReducer.reduce( - OnboardingState(currentPage = 1), - OnboardingIntent.PreviousButtonTapped - ) - assertEquals(0, result.currentPage) - } - - @Test - fun previousButtonTapped_clampsAtZero() { - val result = OnboardingReducer.reduce( - OnboardingState(), - OnboardingIntent.PreviousButtonTapped - ) - assertEquals(0, result.currentPage) - } - - @Test - fun pageSelected_jumpsToIndex() { - val result = OnboardingReducer.reduce( - OnboardingState(), - OnboardingIntent.PageSelected(3) - ) - assertEquals(3, result.currentPage) - } - - @Test - fun skipButtonTapped_jumpsToLastPage() { - val result = OnboardingReducer.reduce( - OnboardingState(), - OnboardingIntent.SkipButtonTapped - ) - assertEquals(4, result.currentPage) - } - - @Test - fun promptChipTapped_updatesSelection() { - val result = OnboardingReducer.reduce( - OnboardingState(), - OnboardingIntent.PromptChipTapped(2) - ) - assertEquals(2, result.demoState.selectedPromptIndex) - } - - @Test - fun addQueuedPromptButtonTapped_incrementsCount() { - val result = OnboardingReducer.reduce( - OnboardingState(), - OnboardingIntent.AddQueuedPromptButtonTapped - ) - assertEquals(3, result.demoState.queuedPromptCount) - } - - @Test - fun reasoningLevelChanged_clampsValue() { - val high = OnboardingReducer.reduce( - OnboardingState(), - OnboardingIntent.ReasoningLevelChanged(1.5) - ) - assertEquals(1.0, high.demoState.reasoningLevel, 0.001) - - val low = OnboardingReducer.reduce( - high, - OnboardingIntent.ReasoningLevelChanged(-0.5) - ) - assertEquals(0.0, low.demoState.reasoningLevel, 0.001) - } - - @Test - fun pairingToggleTapped_togglesState() { - val result = OnboardingReducer.reduce( - OnboardingState(), - OnboardingIntent.PairingToggleTapped - ) - assertFalse(result.demoState.pairingConfirmed) + assertTrue(result.isFinished) } @Test - fun finishButtonTapped_marksFinished() { + fun completionLoaded_setsFinishedFlag() { val result = OnboardingReducer.reduce( OnboardingState(), - OnboardingIntent.FinishButtonTapped + OnboardingIntent.CompletionLoaded(completed = true) ) assertTrue(result.isFinished) } - - @Test - fun navigatingToIdeaStudio_resetsPromptIndexViaStrategy() { - val state = OnboardingState( - demoState = OnboardingState.DemoState(selectedPromptIndex = 2) - ) - val result = OnboardingReducer.reduce(state, OnboardingIntent.PageSelected(1)) - assertEquals(0, result.demoState.selectedPromptIndex) - } - - @Test - fun navigatingToReasoningControl_resetsLevelViaStrategy() { - val state = OnboardingState( - demoState = OnboardingState.DemoState(reasoningLevel = 0.1) - ) - val result = OnboardingReducer.reduce(state, OnboardingIntent.PageSelected(3)) - assertEquals(0.62, result.demoState.reasoningLevel, 0.001) - } } diff --git a/app/src/test/java/io/github/bengidev/opencore/onboarding/domain/OnboardingDomainTest.kt b/app/src/test/java/io/github/bengidev/opencore/onboarding/domain/OnboardingDomainTest.kt index c7733953..87e3b931 100644 --- a/app/src/test/java/io/github/bengidev/opencore/onboarding/domain/OnboardingDomainTest.kt +++ b/app/src/test/java/io/github/bengidev/opencore/onboarding/domain/OnboardingDomainTest.kt @@ -1,57 +1,71 @@ package io.github.bengidev.opencore.onboarding.domain +import io.github.bengidev.opencore.onboarding.presenter.HeroCubeLayoutMath +import io.github.bengidev.opencore.onboarding.presenter.carousel.cardStepOffset +import io.github.bengidev.opencore.onboarding.presenter.carousel.modularRelative +import io.github.bengidev.opencore.onboarding.presenter.carousel.normalizeScrollIndex +import io.github.bengidev.opencore.onboarding.presenter.carousel.wrappedIndex import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test +import androidx.compose.ui.unit.dp -/** - * OpenCoreOnboardingTests (domain suite). - */ class OnboardingDomainTest { @Test - fun onboardingPage_all_hasFivePagesInCorrectOrder() { - assertEquals(5, OnboardingPage.all.size) - assertEquals(OnboardingPageType.EncryptedPairing, OnboardingPage.all[0].type) - assertEquals(OnboardingPageType.IdeaStudio, OnboardingPage.all[1].type) - assertEquals(OnboardingPageType.PromptQueue, OnboardingPage.all[2].type) - assertEquals(OnboardingPageType.ReasoningControl, OnboardingPage.all[3].type) - assertEquals(OnboardingPageType.WorkspaceReady, OnboardingPage.all[4].type) + fun onboardingFeature_catalog_hasFourFeaturesInOrder() { + assertEquals(4, OnboardingFeature.catalog.size) + assertEquals("neural_core", OnboardingFeature.catalog[0].id) + assertEquals("spatial_canvas", OnboardingFeature.catalog[1].id) + assertEquals("workflows", OnboardingFeature.catalog[2].id) + assertEquals("vault", OnboardingFeature.catalog[3].id) } @Test - fun onboardingPage_hasUniqueIds() { - val ids = OnboardingPage.all.map { it.id } + fun onboardingFeature_hasUniqueIds() { + val ids = OnboardingFeature.catalog.map { it.id } assertEquals(ids.size, ids.toSet().size) } @Test - fun onboardingPromptOption_hasThreeSamples() { - assertEquals(3, OnboardingPromptOption.samples.size) - assertEquals("ASK", OnboardingPromptOption.samples[0].label) - assertEquals("WRITE", OnboardingPromptOption.samples[1].label) - assertEquals("EXPLORE", OnboardingPromptOption.samples[2].label) - } + fun heroCubeLayout_morphsFromCenterToHeader() { + val large = HeroCubeLayoutMath.layoutForMorph( + morph = 0f, + width = 400f, + height = 800f, + largeSize = 220f, + smallSize = 36f, + headerTopPadding = 18f, + headerHorizontalPadding = 24f, + safeTop = 0f + ) + val small = HeroCubeLayoutMath.layoutForMorph( + morph = 1f, + width = 400f, + height = 800f, + largeSize = 220f, + smallSize = 36f, + headerTopPadding = 18f, + headerHorizontalPadding = 24f, + safeTop = 0f + ) - @Test - fun onboardingQueueItem_hasFourSamplesWithCorrectStatuses() { - assertEquals(4, OnboardingQueueItem.samples.size) - assertEquals(OnboardingQueueItem.Status.RUNNING, OnboardingQueueItem.samples[0].status) - assertEquals(OnboardingQueueItem.Status.NEXT, OnboardingQueueItem.samples[1].status) - assertEquals(OnboardingQueueItem.Status.QUEUED, OnboardingQueueItem.samples[2].status) - assertEquals(OnboardingQueueItem.Status.READY, OnboardingQueueItem.samples[3].status) + assertEquals(220f, large.size, 0.01f) + assertEquals(36f, small.size, 0.01f) + assertEquals(200f, large.center.x, 0.01f) + assertTrue(small.center.y < large.center.y) } @Test - fun onboardingPageType_hasAllCases() { - assertEquals(5, OnboardingPageType.allCases.size) + fun carouselMath_cardStepIncludesGap() { + val step = cardStepOffset(1f, 300.dp) + assertEquals(314f, step.value, 0.01f) } @Test - fun onboardingDemoDefaults_matchDesignPreviewValues() { - assertEquals(0, OnboardingDemoDefaults.selectedPromptIndex) - assertEquals(0.62, OnboardingDemoDefaults.reasoningLevel, 0.001) - assertEquals(2, OnboardingDemoDefaults.queuedPromptCount) - assertTrue(OnboardingDemoDefaults.pairingConfirmed) + fun carouselMath_wrapsIndices() { + assertEquals(1, wrappedIndex(5, 4)) + assertEquals(0f, modularRelative(0, 4f, 4), 0.01f) + assertEquals(2f, normalizeScrollIndex(6f, 4), 0.01f) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c143137a..95164db9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "9.3.1" +agp = "9.3.2" coreKtx = "1.10.1" junit = "4.13.2" junitVersion = "1.1.5"