Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -16,27 +16,25 @@ 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

| 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.
Original file line number Diff line number Diff line change
@@ -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()
)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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> = 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> = OnboardingPromptOption.samples,
val queueItems: List<OnboardingQueueItem> = OnboardingQueueItem.samples
)
}
val isFinished: Boolean = false
)
Loading
Loading