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
@@ -0,0 +1,116 @@
package io.github.bengidev.opencore.onboarding.presenter.chat

import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.github.bengidev.opencore.onboarding.domain.OnboardingChatMessage
import io.github.bengidev.opencore.onboarding.domain.OnboardingFeature
import io.github.bengidev.opencore.onboarding.theme.OpenCoreOnboardingTheme
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class OnboardingChatPresenterTest {

@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()

@Test
fun reduceMotionFeed_showsUserAndAssistantWithoutThinking() {
val feature = OnboardingFeature.catalog.first()

composeRule.setContent {
OpenCoreOnboardingTheme(darkTheme = false) {
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.height(480.dp)
) {
OnboardingFeatureChatFeedView(
feedActive = true,
reduceMotion = true,
modifier = Modifier.fillMaxSize()
)
}
}
}

composeRule.waitForIdle()
composeRule.onNodeWithText(feature.userPrompt).assertIsDisplayed()
composeRule.onNodeWithText(feature.title).assertIsDisplayed()
assertTrue(composeRule.onAllNodesWithText("Thinking…").fetchSemanticsNodes().isEmpty())
}

@Test
fun chatBubble_rendersUserThinkingAndAssistantRoles() {
val feature = OnboardingFeature.catalog.first()

composeRule.setContent {
OpenCoreOnboardingTheme(darkTheme = false) {
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.height(480.dp)
) {
OnboardingChatBubbleView(
message = OnboardingChatMessage.user(feature.userPrompt, feature),
containerWidth = maxWidth,
reduceMotion = true
)
}
}
}

composeRule.waitForIdle()
composeRule.onNodeWithText(feature.userPrompt).assertIsDisplayed()

composeRule.setContent {
OpenCoreOnboardingTheme(darkTheme = false) {
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.height(480.dp)
) {
OnboardingChatBubbleView(
message = OnboardingChatMessage.thinking(feature),
containerWidth = maxWidth,
reduceMotion = true
)
}
}
}

composeRule.waitForIdle()
composeRule.onNodeWithText("Thinking…").assertIsDisplayed()

composeRule.setContent {
OpenCoreOnboardingTheme(darkTheme = false) {
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.height(480.dp)
) {
OnboardingChatBubbleView(
message = OnboardingChatMessage.assistant(feature),
containerWidth = maxWidth,
reduceMotion = true
)
}
}
}

composeRule.waitForIdle()
composeRule.onNodeWithText(feature.title).assertIsDisplayed()
composeRule.onNodeWithText(feature.subtitle).assertIsDisplayed()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
| **Package** | `io.github.bengidev.opencore.onboarding` |
| **Module** | Internal module inside `:app` |

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.
Single-page onboarding with a wireframe cube hero, looping chat feature feed, usage notice, and swipe-to-start CTA. Persists completion via DataStore, then returns control to the app shell.

## Visibility

Expand All @@ -17,7 +17,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 (`isFinished` only)
- **OnboardingFeature**: Feature catalog for the carousel
- **OnboardingFeature**: Feature catalog for the chat feed
- **ThinkingOrbsKit port** (`thinkingorbs/`): MetalForge thinking-orb procedural animation engine (Canvas)
- **OpenCorePalette**: Graphite monochrome design tokens (OpenCore branding)

## Design patterns
Expand All @@ -31,10 +32,10 @@ The entire onboarding package is an **internal module**: types default to `inter
## Flow

```
Cube hero showoff → morph to header → feature carousel → swipe to start → app shell
Cube hero showoff → morph to header → chat feature feed → swipe to start → app shell
```

## Constraints

- Onboarding must not store provider credentials or model preferences.
- Only completion is persisted; animation and carousel state are local UI state.
- Only completion is persisted; animation and chat feed state are local UI state.
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package io.github.bengidev.opencore.onboarding.domain

import java.util.UUID

internal enum class OnboardingChatRole {
USER,
THINKING,
ASSISTANT
}

internal data class OnboardingChatMessage(
val id: String = UUID.randomUUID().toString(),
val role: OnboardingChatRole,
val feature: OnboardingFeature?,
val text: String,
/** Pre-composed assistant payload for thinking rows — avoids layout work at morph time. */
val preparedAssistant: OnboardingChatMessage? = null
) {
fun morphToAssistant(): OnboardingChatMessage {
preparedAssistant?.let { return it }
val resolvedFeature = feature ?: return this
return copy(
role = OnboardingChatRole.ASSISTANT,
text = resolvedFeature.accessibilitySummary,
preparedAssistant = null
)
}

companion object {
fun user(prompt: String, feature: OnboardingFeature): OnboardingChatMessage =
OnboardingChatMessage(
role = OnboardingChatRole.USER,
feature = feature,
text = prompt
)

fun thinking(feature: OnboardingFeature): OnboardingChatMessage {
val id = UUID.randomUUID().toString()
return OnboardingChatMessage(
id = id,
role = OnboardingChatRole.THINKING,
feature = feature,
text = "Thinking…",
preparedAssistant = OnboardingChatMessage(
id = id,
role = OnboardingChatRole.ASSISTANT,
feature = feature,
text = feature.accessibilitySummary
)
)
}

fun assistant(feature: OnboardingFeature): OnboardingChatMessage =
OnboardingChatMessage(
role = OnboardingChatRole.ASSISTANT,
feature = feature,
text = feature.accessibilitySummary
)
}
}
Original file line number Diff line number Diff line change
@@ -1,48 +1,67 @@
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
val userPrompt: String,
val iconName: String,
val orbStyleIndex: Int
) {
val accessibilitySummary: String
get() = "$title. $subtitle"

val feedAccessibilityLabel: String
get() = "$title. $subtitle. $description"

companion object {
fun wrappedCatalogIndex(index: Int): Int {
val count = catalog.size
if (count == 0) return 0
return ((index % count) + count) % count
}

val catalog: List<OnboardingFeature> = 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
userPrompt = "How does on-device reasoning work?",
iconName = "cpu",
orbStyleIndex = 0
),
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
userPrompt = "Can it map my workspace spatially?",
iconName = "layers",
orbStyleIndex = 1
),
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
userPrompt = "What about automating workflows?",
iconName = "branch",
orbStyleIndex = 2
),
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
userPrompt = "Is my data secure on-device?",
iconName = "lock",
orbStyleIndex = 3
)
)
}
Expand Down
Loading
Loading