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 @@ -5,6 +5,16 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.openbitfun.mobile.app.ui.shell.StartupBrandReveal
import com.openbitfun.mobile.app.ui.shell.ColdStartHomeTransition
import com.openbitfun.mobile.app.ui.shell.LocalColdStartTarget
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.semantics
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
Expand All @@ -23,12 +33,16 @@ import com.openbitfun.mobile.app.viewmodel.AppThemeMode

class MainActivity : ComponentActivity() {
private var showStartupBrand by mutableStateOf(true)
private var showColdStart by mutableStateOf(false)
private var allowColdStart by mutableStateOf(false)
override fun onCreate(savedInstanceState: Bundle?) {
AppLocaleController.applySaved(this)
super.onCreate(savedInstanceState)
showStartupBrand = savedInstanceState == null
val coldStartCandidate = !processLaunchClaimed
&& !intent.getBooleanExtra(DESIGN_PREVIEW_EXTRA, false)
&& StartupRevealPreference.claim(this)
processLaunchClaimed = true
showStartupBrand = coldStartCandidate && StartupRevealPreference.claim(this)
allowColdStart = coldStartCandidate && !showStartupBrand
enableEdgeToEdge()
setContent {
if (intent.getBooleanExtra(DESIGN_PREVIEW_EXTRA, false)) {
Expand All @@ -44,11 +58,30 @@ class MainActivity : ComponentActivity() {
AppThemeMode.DARK -> true
}
OpenBitFunTheme(dark = dark) {
Box {
MobileScreen()
val target = remember { mutableStateOf<androidx.compose.ui.geometry.Rect?>(null) }
var origin by remember { mutableStateOf(Offset.Zero) }
Box(Modifier.onGloballyPositioned { origin = it.positionInRoot() }) {
CompositionLocalProvider(LocalColdStartTarget provides target) {
Box(Modifier.semantics {
if (showStartupBrand || showColdStart) hideFromAccessibility()
}) {
MobileScreen(onAccountRestored = { signedIn ->
if (allowColdStart) {
allowColdStart = false
showColdStart = signedIn
}
})
}
}
if (showStartupBrand) StartupBrandReveal { showStartupBrand = false }
val bounds = target.value
if (showColdStart) {
ColdStartHomeTransition(bounds?.translate(-origin)) { showColdStart = false }
}
}
if (!showStartupBrand && !showColdStart && !allowColdStart) {
com.openbitfun.mobile.app.ui.shell.NotificationOnboarding()
}
if (!showStartupBrand) com.openbitfun.mobile.app.ui.shell.NotificationOnboarding()
}
}
}
Expand All @@ -60,6 +93,8 @@ class MainActivity : ComponentActivity() {

override fun onStop() {
showStartupBrand = false
showColdStart = false
allowColdStart = false
if (!intent.getBooleanExtra(DESIGN_PREVIEW_EXTRA, false)) accountModel().setBackground(true)
super.onStop()
}
Expand All @@ -68,6 +103,7 @@ class MainActivity : ComponentActivity() {
com.openbitfun.mobile.app.viewmodel.AccountViewModel.Factory)[com.openbitfun.mobile.app.viewmodel.AccountViewModel::class.java]

private companion object {
var processLaunchClaimed = false
const val DESIGN_PREVIEW_EXTRA = "openbitfun.design_preview"
const val DESIGN_SCENARIO_EXTRA = "openbitfun.design_scenario"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ internal fun RemoteCompactHome(
.verticalScroll(rememberScrollState())
.padding(horizontal = MobileDesignGeometry.RecentHomeGutter, vertical = 24.dp),
) {
WelcomeBrandFlow(Modifier.align(Alignment.CenterHorizontally).size(MobileDesignGeometry.RecentHomeMarkSize), sweep = true)
com.openbitfun.mobile.app.ui.shell.ColdStartHomeMark(Modifier.align(Alignment.CenterHorizontally).size(MobileDesignGeometry.RecentHomeMarkSize))
Text(stringResource(R.string.home_recent_title), fontSize = 25.sp,
fontWeight = FontWeight.Medium, textAlign = androidx.compose.ui.text.style.TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 32.dp))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package com.openbitfun.mobile.app.ui.shell

import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.unit.dp
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.semantics.clearAndSetSemantics
import com.openbitfun.mobile.app.ui.theme.generated.MobileDesignMotion
import kotlin.math.*

/** Logged-in cold-start transition. The underlying home is already laid out. */
@Composable
internal fun ColdStartHomeTransition(target: androidx.compose.ui.geometry.Rect?, onFinished: () -> Unit) {
val progress = remember { Animatable(0f) }
LaunchedEffect(Unit) {
progress.animateTo(1f, tween(MobileDesignMotion.ColdStartHome, easing = LinearEasing))
onFinished()
}
val density = androidx.compose.ui.platform.LocalDensity.current
val p = progress.value
val travel = smooth((p - .16f) / .52f)
val overlayAlpha = 1f - smooth((p - .68f) / .32f)
BoxWithConstraints(
Modifier.fillMaxSize()
.graphicsLayer { alpha = overlayAlpha }
.background(androidx.compose.material3.MaterialTheme.colorScheme.background)
.pointerInput(Unit) { awaitPointerEventScope { while (true) awaitPointerEvent().changes.forEach { it.consume() } } }
.clearAndSetSemantics { },
contentAlignment = Alignment.TopCenter,
) {
val targetY = target?.let { with(density) { it.center.y.toDp() } } ?: (maxHeight * .53f)
val targetX = target?.let { with(density) { it.center.x.toDp() } } ?: (maxWidth / 2)
val targetSize = target?.let { with(density) { it.width.toDp() } } ?: 56.dp
val centerY = maxHeight * .53f
val y = centerY + (targetY - centerY) * travel - (if (target != null) 9.dp else 0.dp) * sin(travel * PI).toFloat()
val size = 56.dp + (targetSize - 56.dp) * travel
WelcomeBrandFlow(
Modifier.size(size)
.offset(x = (targetX - maxWidth / 2) * travel, y = y - size / 2)
.graphicsLayer { alpha = (p / .13f).coerceIn(0f, 1f) },
sweep = true,
)
}
}

private fun smooth(value: Float): Float {
val p = value.coerceIn(0f, 1f)
return p * p * (3f - 2f * p)
}

internal val LocalColdStartTarget = androidx.compose.runtime.staticCompositionLocalOf<androidx.compose.runtime.MutableState<androidx.compose.ui.geometry.Rect?>?> { null }

@Composable
internal fun ColdStartHomeMark(modifier: Modifier) {
val target = LocalColdStartTarget.current
androidx.compose.runtime.DisposableEffect(target) { onDispose { target?.value = null } }
WelcomeBrandFlow(modifier.onGloballyPositioned { target?.value = it.boundsInRoot() }, sweep = true)
}
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ private fun PaneSeparator(gapWidth: Int) {
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun MobileScreen() {
internal fun MobileScreen(onAccountRestored: (Boolean) -> Unit = {}) {
var compactDrawerOpen by rememberSaveable { mutableStateOf(false) }
val shell = rememberAppShellState()

Expand All @@ -137,6 +137,11 @@ internal fun MobileScreen() {
val accountPhase by accountViewModel.connectionPhase.collectAsStateWithLifecycle()
val accountWorkspaceDirectory by accountViewModel.workspaceDirectory.collectAsStateWithLifecycle()
val readyAccount = accountState as? AccountUiState.Ready
LaunchedEffect(accountState) {
if (accountState !is AccountUiState.Idle && accountState !is AccountUiState.Restoring) {
onAccountRestored(readyAccount?.userId?.isNotBlank() == true)
}
}
val linkContext = androidx.compose.ui.platform.LocalContext.current
var pendingDeviceLink by rememberSaveable { mutableStateOf<String?>(null) }
val connectDeviceLink: (String) -> Unit = { url ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ internal fun WelcomeBrandFlow(modifier: Modifier, sweep: Boolean = false) {
) else remember { mutableFloatStateOf(0f) }
val ink=MaterialTheme.colorScheme.onBackground
Canvas(modifier) {
val phase = animatedPhase.value
val framePhase = animatedPhase.value
// Keep the cover and measured home mark on the same sweep during handoff.
val phase = if (sweep && moving) (android.os.SystemClock.uptimeMillis() % 5000L) / 5000f else framePhase
drawIntoCanvas { canvas ->
val c=canvas.nativeCanvas;c.save();c.scale(size.width/256,size.height/256);paint.color=ink.toArgb()
if(sweep) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -271,4 +271,5 @@ internal object MobileDesignMotion {
const val Quick: Int = 180
const val Structure: Int = 220
const val StartupBrand: Int = 6800
const val ColdStartHome: Int = 2400
}
15 changes: 15 additions & 0 deletions src/apps/mobile/design-system/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,3 +210,18 @@ The recent-home brand and headline are centered above the leading-aligned sessio
HarmonyOS welcome occupies the full window when signed out without an active remote target. It suppresses the workspace sidebar without changing retained selection. At 600vp and above, the brand, phrase and constrained action group are centered without the compact dock; smaller windows retain the stacked dock. The welcome mark is 156vp compact and 184vp wide, using the diagonal sweep. Size changes update this layout in place.

For HarmonyOS welcome windows at least 840vp wide with width/height at least 1.2, a centered composition capped at 1000vp places the brand and actions side by side. This follows available window geometry rather than a device model or fold count.

### Authenticated cold-start home transition

After the persisted first-install reveal has already been claimed, an
authenticated process launch uses `cold_start_home` (2400 ms). The home shell
mounts immediately so restoration and remote loading continue underneath the
cover. The contour mark starts at 56 logical units, then moves to the mark's
measured native bounds during 16%–68% of the timeline; the cover fades from
68%–100%. Each platform measures the actual compact or wide home layout, so
safe-area insets, split windows, and foldable posture changes do not rely on a
fixed coordinate. Signed-out restore, manual login after launch, activity or
scene recreation, foreground resume, and a second root do not claim the
transition. If the home mark is unavailable, the mark remains centered and the
cover still fades on the same clock. Reduced motion finishes immediately, and
accessibility and pointer interaction stay with the cover until it completes.
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,13 @@
"tokens": ["page_bg", "ink", "brand_dot", "startup_brand"],
"platformNotes": "Native first-launch-only overlay on HarmonyOS, Android and iOS. Claim a persisted installation-local flag before playback; account changes, process restarts and upgrades do not reset it. Design previews do not consume it. Existing installs without the flag show it once after upgrade; 6800ms timeline independent of network readiness. A cyan dot hops ahead of ten 42-unit letters with subtle letter bounce. At normalized text time 0.70–0.86 it arcs back to the dotless i, settling at 7.35 units diameter with one fading halo. Text time is min(progress / 0.65 * 0.9, 0.9). Logo expands from 0.65 to 1 with a small overshoot during progress 0.66–0.85 as the word moves down 42 units. Use a centered 280×240 stage, scaled down for narrow windows, 92-unit contour mark on Android/iOS and a 156vp mark on HarmonyOS, with platform-native soft sans typography. HarmonyOS positions the mark at (62, -34) to preserve separation from the settled wordmark. Fade the overlay over the last 3%. Reserve full glyph slots; no layout changes during reveal. Remove on completion or background and do not replay on activity recreation/foreground. Skip for reduced motion. Notification onboarding follows completion. The dedicated brand_dot token preserves identity independently of action/status colors."
},
"cold_start_home_transition": {
"purpose": "Covers a normal authenticated process launch while the measured home brand mark moves into its final position and the page fades in.",
"anatomy": ["home_surface", "moving_contour_brand_mark", "measured_home_mark_anchor", "page_fade"],
"states": ["authenticated_process_launch", "signed_out", "background_resume", "reduce_motion", "compact", "wide"],
"tokens": ["page_bg", "recent_home_mark_size", "cold_start_home"],
"platformNotes": "Only the first authenticated restore in a new process may claim this presentation. The existing installation-local startup_brand_reveal takes precedence and remains unchanged. The home shell mounts underneath; no network request gates the 2400ms timeline. Start the mark at 56 logical units, measure the rendered home mark in the native layout, and interpolate to that measured rect so compact, wide, safe-area, and foldable layouts share the same endpoint. Move through the measured target during 16%–68% of the timeline, then fade the cover from 68%–100%. Signed-out restore, later manual login, foreground resume, activity recreation, and a second scene root do not replay it. Reduced motion completes immediately. If the target is unavailable, keep the mark centered and fade the cover without inventing an endpoint. Hide the underlying shell from accessibility while the cover is active; preserve pointer blocking until completion."
},
"permission_request_panel": {
"purpose": "Answers an independent runtime permission request below the conversation header, outside the transcript, matching mobile-web.",
"anatomy": [
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion src/apps/mobile/design-system/tokens/mobile-tokens.json
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,7 @@
"motion": {
"quick": 180,
"structure": 220,
"startup_brand": 6800
"startup_brand": 6800,
"cold_start_home": 2400
}
}
9 changes: 9 additions & 0 deletions src/apps/mobile/harmonyos/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,15 @@ removing the running action. Test both postures and restore normal App afterward

## Theme and device verification

For cold-start home animation geometry, launch the isolated design preview with
`hdc shell aa start -a EntryAbility -b <bundle-id> --ps openbitfunDesignPreview cold-start-home`
(or `cold-start-home-dark`). Use **Replay** to run the production 2400 ms
transition over the production recent-home component and **Resize** during
playback to remeasure its anchor. This fixture does not load account or remote
state. Check compact and wide windows separately, then force-stop and start the
normal EntryAbility without preview arguments. A fixture check does not replace
authenticated cold-process, signed-out, and background/foreground verification.

- Use existing semantic colors from `Theme.ets`; do not hard-code a light-only foreground or surface color.
- For changes to navigation controls, menus, or responsive presentation, verify compact and wide behavior, light and dark theme legibility, and capture a real-device screenshot before completion when a device is connected.
- Run the smallest matching HarmonyOS build/check plus `pnpm run theme:color-audit:all` for theme or color-related changes.
Expand Down
Loading
Loading