diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/MainActivity.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/MainActivity.kt index c604dc88a9..9213866ec3 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/MainActivity.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/MainActivity.kt @@ -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 @@ -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)) { @@ -44,11 +58,30 @@ class MainActivity : ComponentActivity() { AppThemeMode.DARK -> true } OpenBitFunTheme(dark = dark) { - Box { - MobileScreen() + val target = remember { mutableStateOf(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() } } } @@ -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() } @@ -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" } diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/PairingScreen.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/PairingScreen.kt index 6ba4534fe9..60353653b9 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/PairingScreen.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/PairingScreen.kt @@ -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)) diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/ColdStartHomeTransition.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/ColdStartHomeTransition.kt new file mode 100644 index 0000000000..b45a33b810 --- /dev/null +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/ColdStartHomeTransition.kt @@ -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?> { 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) +} diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/MobileScreen.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/MobileScreen.kt index 37fd435122..29456ece9e 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/MobileScreen.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/MobileScreen.kt @@ -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() @@ -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(null) } val connectDeviceLink: (String) -> Unit = { url -> diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/WelcomeBrandFlow.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/WelcomeBrandFlow.kt index 75207c9141..ef12abd7bc 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/WelcomeBrandFlow.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/WelcomeBrandFlow.kt @@ -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) { diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/theme/generated/MobileDesignTokens.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/theme/generated/MobileDesignTokens.kt index 636927ddda..6f8faac6a2 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/theme/generated/MobileDesignTokens.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/theme/generated/MobileDesignTokens.kt @@ -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 } diff --git a/src/apps/mobile/design-system/README.md b/src/apps/mobile/design-system/README.md index d4e1cc3898..9af6d2d43a 100644 --- a/src/apps/mobile/design-system/README.md +++ b/src/apps/mobile/design-system/README.md @@ -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. diff --git a/src/apps/mobile/design-system/components/mobile-components.json b/src/apps/mobile/design-system/components/mobile-components.json index 166c1731bc..3fd3ff16a8 100644 --- a/src/apps/mobile/design-system/components/mobile-components.json +++ b/src/apps/mobile/design-system/components/mobile-components.json @@ -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": [ diff --git a/src/apps/mobile/design-system/preview/generated/mobile-design-data.js b/src/apps/mobile/design-system/preview/generated/mobile-design-data.js index cf290a686d..d9080f6bb6 100644 --- a/src/apps/mobile/design-system/preview/generated/mobile-design-data.js +++ b/src/apps/mobile/design-system/preview/generated/mobile-design-data.js @@ -431,7 +431,8 @@ export const mobileTokens = { "motion": { "quick": 180, "structure": 220, - "startup_brand": 6800 + "startup_brand": 6800, + "cold_start_home": 2400 } }; export const mobileComponents = { @@ -1234,6 +1235,29 @@ export const mobileComponents = { ], "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": [ diff --git a/src/apps/mobile/design-system/tokens/mobile-tokens.json b/src/apps/mobile/design-system/tokens/mobile-tokens.json index 0a7263a725..5d18b06fc9 100644 --- a/src/apps/mobile/design-system/tokens/mobile-tokens.json +++ b/src/apps/mobile/design-system/tokens/mobile-tokens.json @@ -430,6 +430,7 @@ "motion": { "quick": 180, "structure": 220, - "startup_brand": 6800 + "startup_brand": 6800, + "cold_start_home": 2400 } } diff --git a/src/apps/mobile/harmonyos/AGENTS.md b/src/apps/mobile/harmonyos/AGENTS.md index d43327549e..9d1ada9f07 100644 --- a/src/apps/mobile/harmonyos/AGENTS.md +++ b/src/apps/mobile/harmonyos/AGENTS.md @@ -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 --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. diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets index 60351f8e09..34ac44d932 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets @@ -17,6 +17,7 @@ export default class EntryAbility extends UIAbility { scenarioId === 'streaming-dark' || scenarioId === 'reconnecting-wide' || scenarioId === 'narrow-multiline' || scenarioId === 'fold-context' || scenarioId === 'long-reading' || + scenarioId === 'cold-start-home' || scenarioId === 'cold-start-home-dark' || scenarioId === 'interaction-mailbox' || scenarioId === 'interaction-mailbox-dark') { this.initialPage = 'pages/preview/MobileDesignGallery'; this.previewStorage = new LocalStorage(); @@ -24,9 +25,9 @@ export default class EntryAbility extends UIAbility { AppStorage.setOrCreate('scenarioId', scenarioId); } try { - const colorMode = scenarioId === 'catalog-refresh-dark' || scenarioId === 'device-selector-dark' || scenarioId === 'streaming-dark' || scenarioId === 'interaction-mailbox-dark' + const colorMode = scenarioId === 'cold-start-home-dark' || scenarioId === 'catalog-refresh-dark' || scenarioId === 'device-selector-dark' || scenarioId === 'streaming-dark' || scenarioId === 'interaction-mailbox-dark' ? ConfigurationConstant.ColorMode.COLOR_MODE_DARK - : scenarioId === 'catalog-refresh' || scenarioId === 'device-selector' || scenarioId === 'connected-conversation' || scenarioId === 'reconnecting-wide' + : scenarioId === 'cold-start-home' || scenarioId === 'catalog-refresh' || scenarioId === 'device-selector' || scenarioId === 'connected-conversation' || scenarioId === 'reconnecting-wide' ? ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT : ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET; this.context.getApplicationContext().setColorMode(colorMode); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/generated/MobileDesignTokens.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/generated/MobileDesignTokens.ets index fa5335a702..b4508bbff4 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/generated/MobileDesignTokens.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/generated/MobileDesignTokens.ets @@ -250,4 +250,5 @@ export class MobileDesignMotion { static readonly quick: number = 180; static readonly structure: number = 220; static readonly startupBrand: number = 6800; + static readonly coldStartHome: number = 2400; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets index d7098968a9..27c1fa9861 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets @@ -1,5 +1,7 @@ import { StartupRevealPreference } from '../services/StartupRevealPreference'; import { StartupBrandReveal } from './components/StartupBrandReveal'; +import { ColdStartPresentationState } from './state/ColdStartPresentationState'; +import { ColdStartHomeTransition } from './components/ColdStartHomeTransition'; import { AppRootPresentation } from './components/AppRootPresentation'; import { WatchProvisionCard } from './components/WatchProvisionCard'; import { ArkUiAppRootHostAdapter } from './host/AppRootHostAdapter'; @@ -9,8 +11,11 @@ import { AppRootRuntime } from './runtime/AppRootRuntime'; @ComponentV2 struct AppRoot { @Local showStartupBrand: boolean = false; + @Local showColdStart: boolean = false; private readonly hostAdapter: ArkUiAppRootHostAdapter = new ArkUiAppRootHostAdapter(); private readonly runtime: AppRootRuntime = new AppRootRuntime(this.hostAdapter); + @Provider() coldStartGeometry: ColdStartPresentationState = new ColdStartPresentationState(); + private coldStartConsumed: boolean = false; async aboutToAppear(): Promise { const hostContext = this.getUIContext().getHostContext(); @@ -19,7 +24,14 @@ struct AppRoot { } this.showStartupBrand = StartupRevealPreference.claim(hostContext); this.hostAdapter.attach(hostContext, this.getUIContext()); - await this.runtime.aboutToAppear(); + const firstLaunch = this.showStartupBrand; + const coldStartCandidate = ColdStartPresentationState.claimProcess() && !firstLaunch; + await this.runtime.aboutToAppear((signedIn: boolean): void => { + if (!this.coldStartConsumed) { + this.coldStartConsumed = true; + this.showColdStart = coldStartCandidate && signedIn; + } + }); } onPageShow(): void { @@ -28,10 +40,14 @@ struct AppRoot { onPageHide(): void { this.showStartupBrand = false; + this.showColdStart = false; + this.coldStartConsumed = true; this.runtime.onPageHide(); } aboutToDisappear(): void { + this.coldStartConsumed = true; + this.showColdStart = false; this.runtime.aboutToDisappear(); } @@ -58,10 +74,14 @@ struct AppRoot { deviceId: this.runtime.remoteConnectionController.getDeviceId(), actions: this.runtime.presentationActions }) + .accessibilityLevel(this.showStartupBrand || this.showColdStart ? 'no-hide-descendants' : 'auto') if (this.showStartupBrand) { StartupBrandReveal({ onFinished: () => { this.showStartupBrand = false; } }) } + if (this.showColdStart) { + ColdStartHomeTransition({ geometry: this.coldStartGeometry, onFinished: () => { this.showColdStart = false; } }) + } // Sits above every route on purpose: a watch waiting for approval must // not be hidden behind whatever screen the phone happens to be on. @@ -80,5 +100,9 @@ struct AppRoot { } .width('100%') .height('100%') + .onAreaChange((_old: Area, area: Area) => { + this.coldStartGeometry.originX = Number(area.globalPosition.x); + this.coldStartGeometry.originY = Number(area.globalPosition.y); + }) } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ColdStartHomeTransition.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ColdStartHomeTransition.ets new file mode 100644 index 0000000000..2037a01bdd --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ColdStartHomeTransition.ets @@ -0,0 +1,60 @@ +import { ColdStartPresentationState } from '../state/ColdStartPresentationState'; +import { MotionPreference } from '../../services/MotionPreference'; +import { MobileDesignMotion } from '../../generated/MobileDesignTokens'; +import { WelcomeBrandFlow } from './WelcomeBrandFlow'; +import { PAGE_BG } from './Theme'; + +/** Authenticated cold-start cover. The home page stays mounted underneath. */ +@ComponentV2 +export struct ColdStartHomeTransition { + @Param geometry: ColdStartPresentationState = new ColdStartPresentationState(); + @Local viewportWidth: number = 0; + @Local viewportHeight: number = 0; + @Event onFinished: () => void = () => {}; + @Local progress: number = 0; + private timer: number = -1; + private started: number = 0; + + aboutToAppear(): void { + if (MotionPreference.reduceMotion()) { this.onFinished(); return; } + this.started = Date.now(); + this.timer = setInterval(() => { + if (MotionPreference.reduceMotion()) { clearInterval(this.timer); this.onFinished(); return; } + this.progress = Math.min(1, (Date.now() - this.started) / MobileDesignMotion.coldStartHome); + if (this.progress >= 1) { clearInterval(this.timer); this.onFinished(); } + }, 16); + } + aboutToDisappear(): void { clearInterval(this.timer); } + private clamp(value: number): number { return Math.max(0, Math.min(1, value)); } + private smooth(value: number): number { + const p = this.clamp(value); + return p * p * (3 - 2 * p); + } + private travel(): number { return this.smooth((this.progress - 0.16) / 0.52); } + private markSize(): number { return 56 + ((this.geometry.targetSize > 0 ? this.geometry.targetSize : 56) - 56) * this.travel(); } + private markX(): number { + const center = this.viewportWidth / 2; + if (this.geometry.targetSize <= 0) return center - this.markSize() / 2; + return center + (this.geometry.targetX - this.geometry.originX - center) * this.travel() - this.markSize() / 2; + } + private markY(): number { + const center = this.viewportHeight * 0.53; + if (this.geometry.targetSize <= 0) return center - this.markSize() / 2; + return center + (this.geometry.targetY - this.geometry.originY - center) * this.travel() + - Math.sin(this.travel() * Math.PI) * 9 - this.markSize() / 2; + } + build() { + Stack({ alignContent: Alignment.TopStart }) { + WelcomeBrandFlow({ markSize: this.markSize(), sweep: true }) + .position({ x: this.markX(), y: this.markY() }) + .opacity(this.clamp(this.progress / 0.13)) + } + .width('100%').height('100%').backgroundColor(PAGE_BG) + .onSizeChange((_old: SizeOptions, size: SizeOptions) => { + this.viewportWidth = Number(size.width); + this.viewportHeight = Number(size.height); + }) + .opacity(1 - this.smooth((this.progress - 0.68) / 0.32)) + .hitTestBehavior(HitTestMode.Block).accessibilityLevel('no-hide-descendants') + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RecentRemoteHome.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RecentRemoteHome.ets index f134013865..433d22bfff 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RecentRemoteHome.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RecentRemoteHome.ets @@ -1,3 +1,4 @@ +import { ColdStartPresentationState } from '../state/ColdStartPresentationState'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { MobileDesignGeometry, MobileDesignTypography } from '../../generated/MobileDesignTokens'; import { WelcomeBrandFlow } from './WelcomeBrandFlow'; @@ -8,6 +9,7 @@ import { INK, MUTED, SOFT, LINE, TRANSPARENT } from './Theme'; /** Current-device landing page. Session activation stays with the remote owner. */ @ComponentV2 export struct RecentRemoteHome { + @Consumer() coldStartGeometry: ColdStartPresentationState = new ColdStartPresentationState(); @Param connected: boolean = false; @Param busy: boolean = false; @Param sessions: RemoteSession[] = []; @@ -33,11 +35,18 @@ export struct RecentRemoteHome { return [this.desktopName, workspace].filter((value: string): boolean => value.length > 0).join(' · '); } + aboutToDisappear(): void { this.coldStartGeometry.targetSize = 0; } + build() { Scroll() { Column() { Column() { WelcomeBrandFlow({ markSize: MobileDesignGeometry.recentHomeMarkSize, sweep: true }) + .onAreaChange((_old: Area, area: Area) => { + this.coldStartGeometry.targetX = Number(area.globalPosition.x) + Number(area.width) / 2; + this.coldStartGeometry.targetY = Number(area.globalPosition.y) + Number(area.height) / 2; + this.coldStartGeometry.targetSize = Number(area.width); + }) .margin({ top: 20, bottom: 8 }) Text(RemoteI18n.t('home.recent_title')).fontSize(MobileDesignTypography.headlineLarge.size) .fontWeight(FontWeight.Medium).fontColor(INK).textAlign(TextAlign.Center) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ColdStartHomePreview.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ColdStartHomePreview.ets new file mode 100644 index 0000000000..6a07aa5747 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ColdStartHomePreview.ets @@ -0,0 +1,32 @@ +import { ColdStartPresentationState } from '../state/ColdStartPresentationState'; +import { ColdStartHomeTransition } from '../components/ColdStartHomeTransition'; +import { RecentRemoteHome } from '../components/RecentRemoteHome'; +import { PAGE_BG, INK } from '../components/Theme'; + +/** Isolated native geometry fixture; never loads account or remote state. */ +@ComponentV2 +export struct ColdStartHomePreview { + @Provider() coldStartGeometry: ColdStartPresentationState = new ColdStartPresentationState(); + @Local playing: boolean = false; + @Local compact: boolean = false; + + build() { + Column() { + Row({ space: 12 }) { + Button('Replay').id('launch-replay').onClick(() => { this.playing = true; }) + Button('Resize').id('launch-resize').onClick(() => { this.compact = !this.compact; }) + Text(this.playing ? 'Playing' : 'Home').fontColor(INK) + }.height(60) + Stack() { + RecentRemoteHome() + if (this.playing) { + ColdStartHomeTransition({ geometry: this.coldStartGeometry, onFinished: () => { this.playing = false; } }) + } + }.width(this.compact ? 360 : '100%').layoutWeight(1) + .onAreaChange((_old: Area, area: Area) => { + this.coldStartGeometry.originX = Number(area.globalPosition.x); + this.coldStartGeometry.originY = Number(area.globalPosition.y); + }) + }.width('100%').height('100%').backgroundColor(PAGE_BG) + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets index 79f247c729..2047ff7c21 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets @@ -1,4 +1,5 @@ import { HistoryScrollPreview } from './HistoryScrollPreview'; +import { ColdStartHomePreview } from './ColdStartHomePreview'; import { ComposerSubmissionPreview } from './ComposerSubmissionPreview'; import { DurableTimelinePreview } from './DurableTimelinePreview'; import { DeviceSelectorPreview } from './DeviceSelectorPreview'; @@ -37,7 +38,9 @@ struct MobileDesignGallery { } build() { - if (AppStorage.get('scenarioId') === 'history-scroll') { + if ((AppStorage.get('scenarioId') || '').startsWith('cold-start-home')) { + ColdStartHomePreview() + } else if (AppStorage.get('scenarioId') === 'history-scroll') { HistoryScrollPreview() } else if (AppStorage.get('scenarioId') === 'durable-timeline') { DurableTimelinePreview() diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets index a28a5d5363..e8544b90dc 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets @@ -41,7 +41,7 @@ export class AppRootRuntime extends AppRootRuntimeComposition { super(host); } - async aboutToAppear(): Promise { + async aboutToAppear(onAccountRestored?: (signedIn: boolean) => void): Promise { await this.localeController.initialize(this.host.context()); if (this.host.offerTaskNotifications) { await this.taskCompletionNotificationPort.offerOnboarding( @@ -53,6 +53,7 @@ export class AppRootRuntime extends AppRootRuntimeComposition { await this.remoteSessionListCache.init(this.host.context()); await this.restoreCachedRemoteSessions(); await this.settingsController.initializeCloudAccount(this.host.context()); + if (onAccountRestored) onAccountRestored(this.settingsController.hasCloudAccountSession()); if (this.settingsController.hasCloudAccountSession()) { try { await this.settingsController.listCloudAccountDevices(); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ColdStartPresentationState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ColdStartPresentationState.ets new file mode 100644 index 0000000000..df2e0e8f12 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ColdStartPresentationState.ets @@ -0,0 +1,16 @@ +/** Launch-only presentation state; no account credentials or transport ownership. */ +@ObservedV2 +export class ColdStartPresentationState { + private static processClaimed: boolean = false; + + static claimProcess(): boolean { + if (ColdStartPresentationState.processClaimed) return false; + ColdStartPresentationState.processClaimed = true; + return true; + } + @Trace targetX: number = 0; + @Trace targetY: number = 0; + @Trace targetSize: number = 0; + @Trace originX: number = 0; + @Trace originY: number = 0; +} diff --git a/src/apps/mobile/ios/OpenBitFun/App/OpenBitFunApp.swift b/src/apps/mobile/ios/OpenBitFun/App/OpenBitFunApp.swift index 4c1afe01ff..1a993fa9ba 100644 --- a/src/apps/mobile/ios/OpenBitFun/App/OpenBitFunApp.swift +++ b/src/apps/mobile/ios/OpenBitFun/App/OpenBitFunApp.swift @@ -5,10 +5,13 @@ struct OpenBitFunApp: App { @State private var showStartupBrand = !MobileLaunchConfiguration.streamingRegressionPreview && MobileLaunchConfiguration.designPreviewScenario() == nil && StartupRevealPreference.claim() + @State private var showColdStart = false + @State private var coldStartConsumed = false @State private var notificationOnboardingOpen = false @StateObject private var model = MobileLaunchConfiguration.makeModel() @Environment(\.scenePhase) private var scenePhase private let designPreviewScenario = MobileLaunchConfiguration.designPreviewScenario() + private static var coldStartProcessClaimed = false var body: some Scene { WindowGroup { @@ -22,13 +25,22 @@ struct OpenBitFunApp: App { } else { ZStack { MobileShellView(model: model) - .accessibilityHidden(showStartupBrand) + .accessibilityHidden(showStartupBrand || showColdStart) if showStartupBrand { StartupBrandReveal { showStartupBrand = false } } } - .task(id: showStartupBrand) { - if !showStartupBrand { + .overlayPreferenceValue(ColdStartHomeMarkPreference.self) { anchor in + if showColdStart { + GeometryReader { geometry in + ColdStartHomeTransition(target: anchor.map { geometry[$0] }) { showColdStart = false } + } + } + } + .onAppear { resolveColdStart() } + .onChange(of: model.launchAccountRestored) { _ in resolveColdStart() } + .task(id: showStartupBrand || showColdStart || !coldStartConsumed) { + if !showStartupBrand && !showColdStart && coldStartConsumed { notificationOnboardingOpen = await TaskCompletionNotifier.shouldOfferOnboarding() } } @@ -43,7 +55,7 @@ struct OpenBitFunApp: App { Text(model.localized("允许 OpenBitFun 在任务完成时发送通知。你可以稍后在系统设置中更改。")) } .onChange(of: scenePhase) { phase in - if phase == .background { showStartupBrand = false } + if phase == .background { Self.coldStartProcessClaimed = true; coldStartConsumed = true; showStartupBrand = false; showColdStart = false } model.handleScenePhase(phase) } .environment(\.locale, Locale(identifier: model.appLanguage.rawValue)) @@ -51,4 +63,13 @@ struct OpenBitFunApp: App { } } + private func resolveColdStart() { + guard !coldStartConsumed else { return } + if showStartupBrand { Self.coldStartProcessClaimed = true; coldStartConsumed = true; return } + guard !Self.coldStartProcessClaimed else { coldStartConsumed = true; return } + guard let signedIn = model.launchAccountRestored else { return } + Self.coldStartProcessClaimed = true + coldStartConsumed = true + showColdStart = signedIn && !model.remoteSessionSelected + } } diff --git a/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/GeneratedMobileDesignTokens.swift b/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/GeneratedMobileDesignTokens.swift index f6024069e8..719dc875da 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/GeneratedMobileDesignTokens.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/GeneratedMobileDesignTokens.swift @@ -214,4 +214,5 @@ enum MobileDesignMotion { static let quick: CGFloat = 180 static let structure: CGFloat = 220 static let startupBrand: CGFloat = 6800 + static let coldStartHome: CGFloat = 2400 } diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Remote/RemoteHomeViews.swift b/src/apps/mobile/ios/OpenBitFun/Features/Remote/RemoteHomeViews.swift index d593b7672c..0a1d27d4ec 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Remote/RemoteHomeViews.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Remote/RemoteHomeViews.swift @@ -69,6 +69,7 @@ struct RemoteConnectedHomeView: View { VStack(alignment: .leading, spacing: 0) { WelcomeBrandFlowView(sweep: true) .frame(width: MobileDesignGeometry.recentHomeMarkSize, height: MobileDesignGeometry.recentHomeMarkSize) + .anchorPreference(key: ColdStartHomeMarkPreference.self, value: .bounds) { $0 } .frame(maxWidth: .infinity) .padding(.top, 20) Text(model.localized("今天,想做点什么?")) diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Shell/StartupBrandReveal.swift b/src/apps/mobile/ios/OpenBitFun/Features/Shell/StartupBrandReveal.swift index 87e90f6542..a6a8878ef4 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Shell/StartupBrandReveal.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Shell/StartupBrandReveal.swift @@ -114,6 +114,54 @@ struct StartupBrandReveal: View { private func ease(_ x: Double) -> Double { 1-pow(1-max(0,min(1,x)),3) } } +/// Short transition for an authenticated cold launch. The home view remains +/// mounted underneath so the cover can dissolve into its existing mark. +struct ColdStartHomeTransition: View { + let target: CGRect? + let onFinished: () -> Void + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var started = Date() + private let duration = Double(MobileDesignMotion.coldStartHome) / 1000 + + var body: some View { + GeometryReader { geometry in + TimelineView(.animation(minimumInterval: 1.0 / 60)) { timeline in + let p = reduceMotion ? 1 : min(1, max(0, timeline.date.timeIntervalSince(started) / duration)) + let travel = smooth((p - 0.16) / 0.52) + let targetY = target?.midY ?? geometry.size.height * 0.53 + let centerY = geometry.size.height * 0.53 + let y = centerY + (targetY - centerY) * travel - sin(travel * .pi) * (target == nil ? 0 : 9) + let size = 56 + ((target?.width ?? 56) - 56) * travel + ZStack { + OpenBitFunTheme.page + WelcomeBrandFlowView(sweep: true) + .frame(width: size, height: size) + .position(x: geometry.size.width / 2 + ((target?.midX ?? geometry.size.width / 2) - geometry.size.width / 2) * travel, y: y) + .opacity(min(1, p / 0.13)) + } + .opacity(1 - smooth((p - 0.68) / 0.32)) + } + } + .contentShape(Rectangle()) + .onTapGesture { } + .accessibilityHidden(true) + .task { + guard !reduceMotion else { onFinished(); return } + do { + try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) + onFinished() + } catch { } + } + .onChange(of: reduceMotion) { if $0 { onFinished() } + } + } + + private func smooth(_ x: Double) -> Double { + let v = min(1, max(0, x)) + return v * v * (3 - 2 * v) + } +} + /// Same fixed contour ribbon as desktop AboutBrandMark, with slow highlights. struct WelcomeBrandFlowView: View { @@ -172,3 +220,10 @@ struct WelcomeBrandFlowView: View { }.accessibilityHidden(true) } } + +struct ColdStartHomeMarkPreference: PreferenceKey { + static var defaultValue: Anchor? = nil + static func reduce(value: inout Anchor?, nextValue: () -> Anchor?) { + value = nextValue() ?? value + } +} diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+Account.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+Account.swift index e7c132af24..59a6d90b19 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+Account.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+Account.swift @@ -200,6 +200,12 @@ extension MobileAppModel { // device; a signed-out core would otherwise wipe it on launch. guard !accountLoginPreview, !localActionPreview, !remoteCreatePreview, !directoryFixturePreview, generation == accountGeneration else { return } + defer { + if launchAccountRestored == nil, + !(state is AccountUiStateIdle), !(state is AccountUiStateRestoring) { + launchAccountRestored = state is AccountUiStateReady + } + } accountGeneration = generation if let ready = state as? AccountUiStateReady, let failure = ready.refreshFailure { accountDirectoryError = accountErrorMessage(failure.name, stage: "DEVICE_LIST") diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift index c1f067e9ea..f6fe7993ea 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift @@ -80,6 +80,7 @@ final class MobileAppModel: ObservableObject { @Published var pairingBusy = false @Published var pairingError: String? @Published var coreErrorMessage: String? + @Published var launchAccountRestored: Bool? = nil @Published var accountUser: String? @Published var accountUserID: String? @Published var localDeviceID = ""