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 bb9f0298a8..c604dc88a9 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 @@ -13,6 +13,7 @@ import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.openbitfun.mobile.app.ui.shell.MobileScreen +import com.openbitfun.mobile.app.platform.StartupRevealPreference import com.openbitfun.mobile.app.platform.AppLocaleController import com.openbitfun.mobile.app.ui.preview.MobileDesignGallery import com.openbitfun.mobile.app.ui.preview.mobileDesignScenario @@ -26,6 +27,8 @@ class MainActivity : ComponentActivity() { AppLocaleController.applySaved(this) super.onCreate(savedInstanceState) showStartupBrand = savedInstanceState == null + && !intent.getBooleanExtra(DESIGN_PREVIEW_EXTRA, false) + && StartupRevealPreference.claim(this) enableEdgeToEdge() setContent { if (intent.getBooleanExtra(DESIGN_PREVIEW_EXTRA, false)) { diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/platform/StartupRevealPreference.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/platform/StartupRevealPreference.kt new file mode 100644 index 0000000000..f19d150fcd --- /dev/null +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/platform/StartupRevealPreference.kt @@ -0,0 +1,15 @@ +package com.openbitfun.mobile.app.platform + +import android.content.Context + +/** Installation-local presentation state; account changes and upgrades do not reset it. */ +internal object StartupRevealPreference { + @Synchronized + fun claim(context: Context): Boolean { + val preferences = context.getSharedPreferences("startup_presentation", Context.MODE_PRIVATE) + if (preferences.getBoolean("brand_reveal_shown", false)) return false + // Claim before playback, including when the first reveal is interrupted. + preferences.edit().putBoolean("brand_reveal_shown", true).apply() + return true + } +} diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationHeader.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationHeader.kt index 719fe33f4e..975595e791 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationHeader.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationHeader.kt @@ -81,8 +81,7 @@ internal fun ConversationHeader( modifier = Modifier .fillMaxWidth() .height( - if (hasSubtitle) MobileDesignGeometry.ConversationHeaderHeight - else MobileDesignGeometry.ConversationHeaderCompactHeight, + MobileDesignGeometry.ConversationHeaderHeight, ) .padding(horizontal = MobileDesignGeometry.ContentGutter, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/GeneralChatScreen.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/GeneralChatScreen.kt index 80c3fbf6f3..131b0f8b97 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/GeneralChatScreen.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/GeneralChatScreen.kt @@ -190,7 +190,7 @@ internal fun GeneralChatScreen( Row( modifier = Modifier .fillMaxWidth() - .height(64.dp) + .height(com.openbitfun.mobile.app.ui.theme.generated.MobileDesignGeometry.ConversationHeaderHeight) .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/OpenBitFunCompactDrawer.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/OpenBitFunCompactDrawer.kt index d957b34850..c5a5f04ed4 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/OpenBitFunCompactDrawer.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/OpenBitFunCompactDrawer.kt @@ -249,7 +249,7 @@ internal fun OpenBitFunCompactDrawer( translationX = drawerWidthPx * contentProgress.value scaleX = 1f - (1f - CONTENT_SCALE_X) * contentProgress.value scaleY = 1f - (1f - CONTENT_SCALE_Y) * contentProgress.value - transformOrigin = TransformOrigin(0f, 0.5f) + transformOrigin = TransformOrigin(0f, com.openbitfun.mobile.app.ui.theme.generated.MobileDesignGeometry.ConversationHeaderHeight.toPx() / 2f / size.height.coerceAtLeast(1f)) } .then( if (contentCard.phase == ContentCardPhase.Receded) { diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/AppSidebar.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/AppSidebar.kt index 1c2248ec6b..9c9e36d61c 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/AppSidebar.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/AppSidebar.kt @@ -107,7 +107,7 @@ internal fun AppSidebar( Column( modifier = Modifier .fillMaxSize() - .padding(start = 20.dp, end = 20.dp, top = 4.dp, bottom = 16.dp), + .padding(start = 20.dp, end = 20.dp, top = 0.dp, bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { if (signedIn) { diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/SidebarChrome.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/SidebarChrome.kt index 80096af7a0..7e18a6fb2a 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/SidebarChrome.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/SidebarChrome.kt @@ -62,8 +62,7 @@ internal fun SidebarCircleButton( @Composable internal fun ConnectionDot(phase: ConnectionPhase) { val tone = ConnectionStatusPresenter.tone(phase) - // Keep recovery neutral across native sidebars; the status label and - // accessibility state distinguish checking from a disconnected link. + // Keep automatic recovery quiet; color and accessibility state express reachability. val color: Color = when (tone) { ConnectionTone.OK -> openBitFunColors.statusSuccess ConnectionTone.BUSY -> MaterialTheme.colorScheme.onSurfaceVariant diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/SidebarDeviceSelectorRow.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/SidebarDeviceSelectorRow.kt new file mode 100644 index 0000000000..5686e1c64d --- /dev/null +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/SidebarDeviceSelectorRow.kt @@ -0,0 +1,87 @@ +package com.openbitfun.mobile.app.ui.shell.sidebar + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +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.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.stateDescription +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 com.openbitfun.mobile.app.R +import com.openbitfun.mobile.app.ui.common.labelRes +import com.openbitfun.mobile.core.feature.connection.ConnectionPhase + +@Composable +internal fun SidebarDeviceSelectorRow( + deviceName: String, + online: Boolean, + selected: Boolean, + loading: Boolean, + phase: ConnectionPhase? = null, + onSelect: () -> Unit, +) { + val deviceLabel = deviceName + val healthLabel = phase?.let { stringResource(it.labelRes()) } + Row( + modifier = Modifier + .fillMaxWidth() + .height(52.dp) + .clip(RoundedCornerShape(10.dp)) + .background(if (selected) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.surface.copy(alpha = 0f)) + .clickable(enabled = online || (selected && phase in listOf(ConnectionPhase.FAILED, ConnectionPhase.DISCONNECTED)), role = Role.Button, onClick = onSelect) + .semantics(mergeDescendants = true) { + this.selected = selected + contentDescription = deviceLabel + healthLabel?.let { stateDescription = it } + } + .padding(start = 10.dp, end = 6.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painterResource(R.drawable.ic_symbol_desktop), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(21.dp), + ) + Text( + deviceName, + fontSize = 14.sp, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (loading) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.onSurfaceVariant, + strokeWidth = 1.5.dp, + modifier = Modifier.size(14.dp), + ) + } else { + ConnectionDot(phase ?: if (online) ConnectionPhase.CONNECTED else ConnectionPhase.DISCONNECTED) + } + } +} diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/SidebarFooter.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/SidebarFooter.kt index bcc54bb135..c491030452 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/SidebarFooter.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/SidebarFooter.kt @@ -9,7 +9,8 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -18,7 +19,6 @@ 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.draw.shadow import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -34,44 +34,38 @@ import com.openbitfun.mobile.app.ui.common.SignedOutConnectionActions internal const val SIDEBAR_NEW_CHAT_TEST_TAG: String = "app-sidebar-new-chat" internal const val SIDEBAR_SETTINGS_TEST_TAG: String = "app-sidebar-settings" -/** - * The signed-in footer, ported from `AuthenticatedFooter` in `AppSidebar.ets`. - * - * Carded and floating over the list rather than docked below it: the list scrolls - * behind it, so starting a new conversation stays reachable no matter how far - * down the history a user has gone. - */ +/** Shared tools/settings navigation for compact and wide sidebars. */ @Composable -internal fun SidebarAuthenticatedFooter(onConnect: () -> Unit, onOpenSettings: () -> Unit) { - val newChatLabel = stringResource(R.string.device_tools) +internal fun SidebarAuthenticatedFooter(onOpenTools: () -> Unit, onOpenSettings: () -> Unit) { + val toolsLabel = stringResource(R.string.device_tools) Row( modifier = Modifier.fillMaxWidth().height(56.dp), verticalAlignment = Alignment.CenterVertically, ) { Row( modifier = Modifier - .width(116.dp) - .height(46.dp) - .shadow(2.dp, RoundedCornerShape(23.dp)) - .clip(RoundedCornerShape(23.dp)) + .widthIn(min = 104.dp) + .height(48.dp) + .clip(RoundedCornerShape(24.dp)) .background(MaterialTheme.colorScheme.surface) - .border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(23.dp)) - .clickable(role = Role.Button, onClick = onConnect) + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(24.dp)) + .clickable(role = Role.Button, onClick = onOpenTools) .semantics(mergeDescendants = true) { - contentDescription = newChatLabel + contentDescription = toolsLabel } - .testTag(SIDEBAR_NEW_CHAT_TEST_TAG), - horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), + .testTag(SIDEBAR_NEW_CHAT_TEST_TAG) + .padding(horizontal = 14.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically, ) { Icon( - painterResource(R.drawable.ic_symbol_folder), + painterResource(R.drawable.ic_symbol_wrench_and_screwdriver), contentDescription = null, tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(18.dp), + modifier = Modifier.size(24.dp), ) Text( - newChatLabel, + toolsLabel, fontSize = 15.sp, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface, @@ -81,7 +75,7 @@ internal fun SidebarAuthenticatedFooter(onConnect: () -> Unit, onOpenSettings: ( SidebarCircleButton( icon = R.drawable.ic_symbol_gearshape, contentDescription = stringResource(R.string.navigation_settings), - diameter = 46, + diameter = 48, onClick = onOpenSettings, modifier = Modifier.testTag(SIDEBAR_SETTINGS_TEST_TAG), ) diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/SidebarHeader.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/SidebarHeader.kt index 90740ac83e..27e2c38027 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/SidebarHeader.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/SidebarHeader.kt @@ -44,7 +44,7 @@ internal fun SidebarAuthenticatedHeader( onToggleSearch: () -> Unit, ) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().height(com.openbitfun.mobile.app.ui.theme.generated.MobileDesignGeometry.ConversationHeaderHeight), verticalAlignment = Alignment.CenterVertically, ) { Text( diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/SidebarRemoteWorkspaceSection.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/SidebarRemoteWorkspaceSection.kt index dd8f781bf4..b6243cc1fd 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/SidebarRemoteWorkspaceSection.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/SidebarRemoteWorkspaceSection.kt @@ -19,13 +19,12 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -38,22 +37,18 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.openbitfun.mobile.app.ui.remote.ProjectCreateControl -import com.openbitfun.mobile.core.feature.session.HarnessProfilePolicy import com.openbitfun.mobile.app.R -import com.openbitfun.mobile.app.ui.common.labelRes import com.openbitfun.mobile.core.feature.account.AccountDeviceUi import com.openbitfun.mobile.core.feature.connection.ConnectionPhase import com.openbitfun.mobile.core.feature.connection.ConnectionStatusPresenter import com.openbitfun.mobile.core.feature.connection.RemoteControlSource import com.openbitfun.mobile.core.feature.shell.RemoteSidebarSessionRow -import com.openbitfun.mobile.core.feature.shell.RemoteSidebarWorkspaceRow import com.openbitfun.mobile.core.feature.session.RemoteSessionUiState import com.openbitfun.mobile.core.feature.workspace.RemoteWorkspaceUiState import com.openbitfun.mobile.app.ui.theme.openBitFunColors @@ -92,26 +87,8 @@ internal fun SidebarRemoteWorkspaceSection( val refreshDevicesLabel = stringResource(R.string.account_devices_refresh) val projectedDevices = devices val activeDeviceId = if (controlSource == RemoteControlSource.ACCOUNT_DEVICE) selectedDeviceId else null - var expandedDeviceIds by rememberSaveable { mutableStateOf(emptyList()) } var visibleDeviceCount by rememberSaveable { mutableStateOf(DEVICES_PER_BATCH) } - var cachedRemoteStates by remember { - mutableStateOf(emptyMap()) - } - var cachedWorkspaceStates by remember { - mutableStateOf(emptyMap()) - } - - LaunchedEffect(activeDeviceId, remoteState, workspaceState) { - activeDeviceId?.let { id -> - if (id !in expandedDeviceIds) expandedDeviceIds = expandedDeviceIds + id - (remoteState as? RemoteSessionUiState.Ready)?.let { ready -> - cachedRemoteStates = cachedRemoteStates + (id to ready) - } - (workspaceState as? RemoteWorkspaceUiState.Ready)?.let { ready -> - cachedWorkspaceStates = cachedWorkspaceStates + (id to ready) - } - } - } + val workspacePanels = androidx.compose.runtime.saveable.rememberSaveableStateHolder() Column(modifier = Modifier.fillMaxWidth().padding(top = 18.dp)) { Row( @@ -183,90 +160,54 @@ internal fun SidebarRemoteWorkspaceSection( ) } else { projectedDevices.take(visibleDeviceCount).forEach { device -> - val expanded = device.id in expandedDeviceIds val active = device.id == activeDeviceId - val cachedRemote = cachedRemoteStates[device.id] - val cachedWorkspace = cachedWorkspaceStates[device.id] - val shownRemote = if (active) { - (remoteState as? RemoteSessionUiState.Ready) ?: cachedRemote - } else { - cachedRemote - } - val shownWorkspace = if (active) { - (workspaceState as? RemoteWorkspaceUiState.Ready) ?: cachedWorkspace - } else { - cachedWorkspace - } - val loading = active && ( - remoteState is RemoteSessionUiState.Idle || - remoteState is RemoteSessionUiState.Loading || - workspaceState is RemoteWorkspaceUiState.Idle || - workspaceState is RemoteWorkspaceUiState.Loading - ) - val failed = active && ( - remoteState is RemoteSessionUiState.Failed || - workspaceState is RemoteWorkspaceUiState.Failed - ) - SidebarDeviceHeader( + SidebarDeviceSelectorRow( deviceName = device.name.ifBlank { device.id }, online = device.online, selected = active, - expanded = expanded, - loading = loading, + loading = active && (remoteState is RemoteSessionUiState.Loading || + workspaceState is RemoteWorkspaceUiState.Loading), phase = if (active) connectionPhase else null, - onToggle = { - if (active) { - expandedDeviceIds = if (expanded) { - expandedDeviceIds - device.id - } else { - expandedDeviceIds + device.id - } - } else if (device.online) { - activeDeviceId?.let { currentId -> - (remoteState as? RemoteSessionUiState.Ready)?.let { ready -> - cachedRemoteStates = cachedRemoteStates + (currentId to ready) - } - (workspaceState as? RemoteWorkspaceUiState.Ready)?.let { ready -> - cachedWorkspaceStates = cachedWorkspaceStates + (currentId to ready) - } - } - if (!expanded) expandedDeviceIds = expandedDeviceIds + device.id + onSelect = { + if (active && connectionPhase in listOf(ConnectionPhase.FAILED, ConnectionPhase.DISCONNECTED)) { + onRetryActive() + } else if (!active && device.online) { onSelectDevice(device.id) } }, ) - if (active && connectionPhase in listOf(ConnectionPhase.RECONNECTING, ConnectionPhase.FAILED, ConnectionPhase.DISCONNECTED)) { - SidebarConnectionRecovery(connectionPhase, onRetryActive) - } - if (expanded) { + } + if (visibleDeviceCount < projectedDevices.size) { + MoreRow( + hidden = projectedDevices.size - visibleDeviceCount, + startPadding = 10, + onClick = { visibleDeviceCount += DEVICES_PER_BATCH }, + devices = true, + ) + } + activeDeviceId?.let { id -> + workspacePanels.SaveableStateProvider(id) { SidebarActiveDeviceBody( - connected = if (active) connected else shownWorkspace != null, - loading = loading, - failed = failed, - deviceKey = device.id, - remoteState = shownRemote, - workspaceState = shownWorkspace, + connected = connected, + loading = remoteState is RemoteSessionUiState.Idle || remoteState is RemoteSessionUiState.Loading || + workspaceState is RemoteWorkspaceUiState.Idle || workspaceState is RemoteWorkspaceUiState.Loading, + failed = remoteState is RemoteSessionUiState.Failed || workspaceState is RemoteWorkspaceUiState.Failed, + deviceKey = id, + remoteState = remoteState as? RemoteSessionUiState.Ready, + workspaceState = workspaceState as? RemoteWorkspaceUiState.Ready, selectedSessionId = selectedSessionId, onConnect = onConnect, - onRetry = { onSelectDevice(device.id) }, + onRetry = onRetryActive, onOpenSession = onOpenSession, onOpenActions = onOpenActions, - canActOnSessions = active, + canActOnSessions = true, onCreateInWorkspace = onCreateInWorkspace, onOpenWorkspace = onOpenWorkspace, - onAddWorkspace = onAddWorkspace, - onWorkspaceTool = onWorkspaceTool, + onAddWorkspace = onAddWorkspace, + onWorkspaceTool = onWorkspaceTool, ) } } - if (visibleDeviceCount < projectedDevices.size) { - MoreRow( - hidden = projectedDevices.size - visibleDeviceCount, - startPadding = 10, - onClick = { visibleDeviceCount += DEVICES_PER_BATCH }, - devices = true, - ) - } } } } @@ -456,93 +397,6 @@ private fun SidebarActiveDeviceBody( } } -@Composable -private fun SidebarDeviceHeader( - deviceName: String, - online: Boolean, - selected: Boolean, - expanded: Boolean, - loading: Boolean, - phase: ConnectionPhase? = null, - onToggle: () -> Unit, -) { - val deviceLabel = deviceName - val healthLabel = phase?.let { stringResource(it.labelRes()) } - Row( - modifier = Modifier - .fillMaxWidth() - .height(46.dp) - .clip(RoundedCornerShape(10.dp)) - .clickable(role = Role.Button, onClick = onToggle) - .semantics(mergeDescendants = true) { - contentDescription = deviceLabel - healthLabel?.let { stateDescription = it } - } - .padding(start = 10.dp, end = 6.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - painterResource(R.drawable.ic_symbol_desktop), - contentDescription = null, - tint = if (online) MaterialTheme.colorScheme.onSurface - else MaterialTheme.colorScheme.outline, - modifier = Modifier.size(21.dp), - ) - Text( - deviceName, - fontSize = 14.sp, - fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, - color = if (online) MaterialTheme.colorScheme.onSurface - else MaterialTheme.colorScheme.outline, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - if (phase != null) { - ConnectionDot(phase) - } else if (!online) { - Text( - stringResource(R.string.sidebar_device_offline), - fontSize = 12.sp, - color = MaterialTheme.colorScheme.outline, - ) - } - if (loading) { - CircularProgressIndicator( - color = MaterialTheme.colorScheme.onSurfaceVariant, - strokeWidth = 1.5.dp, - modifier = Modifier.size(14.dp), - ) - } - Icon( - painterResource( - if (expanded) R.drawable.ic_symbol_chevron_down - else R.drawable.ic_symbol_chevron_right, - ), - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(13.dp), - ) - } -} - -@Composable -internal fun SidebarConnectionRecovery(phase: ConnectionPhase, onRetry: () -> Unit) { - val retryLabel = stringResource(R.string.sidebar_device_retry) - Row( - modifier = Modifier.fillMaxWidth().height(44.dp).padding(start = 10.dp, end = 6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text(stringResource(phase.labelRes()), fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f)) - IconButton(onClick = onRetry, modifier = Modifier.size(44.dp).testTag("sidebar-retry-connection")) { - Icon(painterResource(R.drawable.ic_symbol_arrow_clockwise), contentDescription = retryLabel, - tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(17.dp)) - } - } -} - @Composable private fun DeviceLoadingRow() { Row( diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/viewmodel/AccountViewModel.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/viewmodel/AccountViewModel.kt index 153a092141..f3dab2e143 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/viewmodel/AccountViewModel.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/viewmodel/AccountViewModel.kt @@ -94,7 +94,21 @@ internal class AccountViewModel(application: Application) : AndroidViewModel(app * a way back. */ fun selectDevice(deviceId: String) { - if (deviceId == activeTarget) bindTarget(deviceId) else store.dispatch(AccountIntent.SelectDevice(deviceId)) + if (deviceId == activeTarget) { + bindTarget(deviceId) + } else { + // Clear the outgoing projection before publishing the new selection. + // The account collector binds the new target's shared stores. + val ready = state.value as? AccountUiState.Ready ?: return + if (ready.devices.none { it.id == deviceId && it.online }) return + remoteJob?.cancel() + workspaceJob?.cancel() + connectionJob?.cancel() + _remoteState.value = RemoteSessionUiState.Loading + _workspaceState.value = RemoteWorkspaceUiState.Loading + _connectionPhase.value = ConnectionPhase.IDLE + store.dispatch(AccountIntent.SelectDevice(deviceId)) + } } fun disconnectDevice() { diff --git a/src/apps/mobile/android/app/src/main/res/values-zh/strings.xml b/src/apps/mobile/android/app/src/main/res/values-zh/strings.xml index 19dfd78848..1cad722afc 100644 --- a/src/apps/mobile/android/app/src/main/res/values-zh/strings.xml +++ b/src/apps/mobile/android/app/src/main/res/values-zh/strings.xml @@ -494,7 +494,7 @@ 修改时间:最新优先 修改时间:最早优先 工作区操作 - 设备工具 + 工具 计划 查看计划 diff --git a/src/apps/mobile/android/app/src/main/res/values/strings.xml b/src/apps/mobile/android/app/src/main/res/values/strings.xml index 885bc0b4fc..e6817f7081 100644 --- a/src/apps/mobile/android/app/src/main/res/values/strings.xml +++ b/src/apps/mobile/android/app/src/main/res/values/strings.xml @@ -509,7 +509,7 @@ Oldest first Terminal Workspace actions - Device tools + Tools Plan View plan diff --git a/src/apps/mobile/design-system/components/mobile-components.json b/src/apps/mobile/design-system/components/mobile-components.json index 9c813b9b21..ed44fc1916 100644 --- a/src/apps/mobile/design-system/components/mobile-components.json +++ b/src/apps/mobile/design-system/components/mobile-components.json @@ -1,6 +1,27 @@ { "version": 1, "components": { + "shell_header_alignment": { + "purpose": "Keep sidebar search and conversation menu controls on one horizontal centerline.", + "anatomy": ["sidebar_header", "conversation_header"], + "states": ["drawer_open", "drawer_closed", "wide", "with_subtitle", "without_subtitle"], + "tokens": ["conversation_header_height"], + "platformNotes": "Both signed-in header rows use conversationHeaderHeight from the same safe-area origin, without a sidebar-only top inset. Preserve this height when a subtitle appears or disappears. Anchor drawer content scaling vertically at half the header height so reveal animation cannot shift its control centerline." + }, + "sidebar_device_selector": { + "purpose": "Select one remote device and show its workspace panel below the device list, matching mobile web.", + "anatomy": ["device_icon", "device_name", "connection_indicator", "selected_workspace_panel"], + "states": ["selected", "online", "offline", "loading"], + "tokens": ["soft", "ink", "muted", "status_success"], + "platformNotes": "Device rows have no disclosure chevron. Re-selecting the current device does not collapse its workspaces or restart a healthy connection. Use a soft selected surface and normal ink for device identity; only the separate status indicator expresses reachability. Do not add a connection status text row or recovery banner below the device. Automatic reconnect stays quiet; tapping the selected failed or disconnected device retries the connection. Workspace disclosure remains independent and survives refresh and device switching. Compact and wide layouts use the same selection semantics." + }, + "sidebar_tools_footer": { + "purpose": "Open controlled-device tools without creating a chat; keep settings separately reachable.", + "anatomy": ["tools_icon_and_label", "flexible_gap", "settings_button"], + "states": ["enabled", "disabled", "pressed"], + "tokens": ["card", "line", "ink"], + "platformNotes": "Use Tools / 工具 for both the sidebar entry and destination title. Render a native wrench-and-screwdriver symbol (or the existing platform vector) in a 24-unit slot, 8-unit label gap, 15-unit medium text, 14-unit horizontal padding and a minimum 104 by 48 pill. Keep the 48-unit settings action separate. Use a quiet surface and outline without raised shadows. Compact and wide sidebars reuse a pure footer component with state and callbacks; create-session validation must not affect tools appearance." + }, "circle_control": { "purpose": "A compact navigation or overflow control with a full-size touch target.", "anatomy": ["touch_target", "centered_glyph", "optional_pressed_surface"], @@ -170,11 +191,11 @@ "platformNotes": "A layout with C staggered slide motion. Mark never animates. Per-glyph entry: 180ms + index*55ms, 800ms cubic ease-out, 38px slide with 5px settling overshoot. Exit is 650ms, 30px left. Respect reduced motion and stop work offscreen. Retain real account/scan routing; only show MiniApps on hosts that implement them. Safe-area insets remain native." }, "startup_brand_reveal": { - "purpose": "Provides a short desktop-inspired identity transition on cold launch without waiting for network or account loading.", + "purpose": "Provides a desktop-inspired identity transition on the first normal launch of an installation without waiting for network or account loading.", "anatomy": ["theme_background", "bouncing_brand_dot", "staggered_wordmark", "desktop_contour_brand_mark"], - "states": ["cold_start", "reduced_motion", "backgrounded"], + "states": ["first_launch", "already_shown", "reduced_motion", "backgrounded"], "tokens": ["page_bg", "ink", "brand_dot", "startup_brand"], - "platformNotes": "Native cold-start overlay on HarmonyOS, Android and iOS; 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." + "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." }, "permission_request_panel": { "purpose": "Answers an independent runtime permission request below the conversation header, outside the transcript, matching mobile-web.", 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 332c89c447..09d1795ce8 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 @@ -386,6 +386,65 @@ export const mobileTokens = { export const mobileComponents = { "version": 1, "components": { + "shell_header_alignment": { + "purpose": "Keep sidebar search and conversation menu controls on one horizontal centerline.", + "anatomy": [ + "sidebar_header", + "conversation_header" + ], + "states": [ + "drawer_open", + "drawer_closed", + "wide", + "with_subtitle", + "without_subtitle" + ], + "tokens": [ + "conversation_header_height" + ], + "platformNotes": "Both signed-in header rows use conversationHeaderHeight from the same safe-area origin, without a sidebar-only top inset. Preserve this height when a subtitle appears or disappears. Anchor drawer content scaling vertically at half the header height so reveal animation cannot shift its control centerline." + }, + "sidebar_device_selector": { + "purpose": "Select one remote device and show its workspace panel below the device list, matching mobile web.", + "anatomy": [ + "device_icon", + "device_name", + "connection_indicator", + "selected_workspace_panel" + ], + "states": [ + "selected", + "online", + "offline", + "loading" + ], + "tokens": [ + "soft", + "ink", + "muted", + "status_success" + ], + "platformNotes": "Device rows have no disclosure chevron. Re-selecting the current device does not collapse its workspaces or restart a healthy connection. Use a soft selected surface and normal ink for device identity; only the separate status indicator expresses reachability. Do not add a connection status text row or recovery banner below the device. Automatic reconnect stays quiet; tapping the selected failed or disconnected device retries the connection. Workspace disclosure remains independent and survives refresh and device switching. Compact and wide layouts use the same selection semantics." + }, + "sidebar_tools_footer": { + "purpose": "Open controlled-device tools without creating a chat; keep settings separately reachable.", + "anatomy": [ + "tools_icon_and_label", + "flexible_gap", + "settings_button" + ], + "states": [ + "enabled", + "disabled", + "pressed" + ], + "tokens": [ + "card", + "line", + "ink" + ], + "platformNotes": "Use Tools / 工具 for both the sidebar entry and destination title. Render a native wrench-and-screwdriver symbol (or the existing platform vector) in a 24-unit slot, 8-unit label gap, 15-unit medium text, 14-unit horizontal padding and a minimum 104 by 48 pill. Keep the 48-unit settings action separate. Use a quiet surface and outline without raised shadows. Compact and wide sidebars reuse a pure footer component with state and callbacks; create-session validation must not affect tools appearance." + }, "circle_control": { "purpose": "A compact navigation or overflow control with a full-size touch target.", "anatomy": [ @@ -1054,7 +1113,7 @@ export const mobileComponents = { "platformNotes": "A layout with C staggered slide motion. Mark never animates. Per-glyph entry: 180ms + index*55ms, 800ms cubic ease-out, 38px slide with 5px settling overshoot. Exit is 650ms, 30px left. Respect reduced motion and stop work offscreen. Retain real account/scan routing; only show MiniApps on hosts that implement them. Safe-area insets remain native." }, "startup_brand_reveal": { - "purpose": "Provides a short desktop-inspired identity transition on cold launch without waiting for network or account loading.", + "purpose": "Provides a desktop-inspired identity transition on the first normal launch of an installation without waiting for network or account loading.", "anatomy": [ "theme_background", "bouncing_brand_dot", @@ -1062,7 +1121,8 @@ export const mobileComponents = { "desktop_contour_brand_mark" ], "states": [ - "cold_start", + "first_launch", + "already_shown", "reduced_motion", "backgrounded" ], @@ -1072,7 +1132,7 @@ export const mobileComponents = { "brand_dot", "startup_brand" ], - "platformNotes": "Native cold-start overlay on HarmonyOS, Android and iOS; 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." + "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." }, "permission_request_panel": { "purpose": "Answers an independent runtime permission request below the conversation header, outside the transcript, matching mobile-web.", 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 13f5438b36..968ef088b9 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 @@ -13,7 +13,7 @@ export default class EntryAbility extends UIAbility { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { const scenarioId = want.parameters?.['openbitfunDesignPreview']; - if (scenarioId === 'welcome-home' || scenarioId === 'connected-conversation' || + if (scenarioId === 'device-selector' || scenarioId === 'device-selector-dark' || scenarioId === 'welcome-home' || scenarioId === 'connected-conversation' || scenarioId === 'streaming-dark' || scenarioId === 'reconnecting-wide' || scenarioId === 'narrow-multiline' || scenarioId === 'fold-context' || scenarioId === 'long-reading' || @@ -24,9 +24,9 @@ export default class EntryAbility extends UIAbility { AppStorage.setOrCreate('scenarioId', scenarioId); } try { - const colorMode = scenarioId === 'streaming-dark' || scenarioId === 'interaction-mailbox-dark' + const colorMode = scenarioId === 'device-selector-dark' || scenarioId === 'streaming-dark' || scenarioId === 'interaction-mailbox-dark' ? ConfigurationConstant.ColorMode.COLOR_MODE_DARK - : scenarioId === 'connected-conversation' || scenarioId === 'reconnecting-wide' + : 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/i18n/EnUsMessages.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets index ee07feed63..5674f18741 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets @@ -17,7 +17,7 @@ export const EN_US_MESSAGES: [string, string][] = [ ['workspaceTools.selectLocation', 'Select a location'], ['workspaceTools.runtimeLocal', 'This runtime'], ['workspaceTools.download', 'Download'], - ['workspaceTools.title', 'Device tools'], + ['workspaceTools.title', 'Tools'], ['workspaceTools.files', 'Files'], ['workspaceTools.selectTerminal', 'Select an existing terminal'], ['workspaceTools.terminal', 'Terminal'], diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets index da286b2c76..f32183e110 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets @@ -17,7 +17,7 @@ export const ZH_CN_MESSAGES: [string, string][] = [ ['workspaceTools.selectLocation', '选择位置'], ['workspaceTools.runtimeLocal', '受控设备本机'], ['workspaceTools.download', '下载'], - ['workspaceTools.title', '设备工具'], + ['workspaceTools.title', '工具'], ['workspaceTools.files', '文件'], ['workspaceTools.selectTerminal', '选择已有终端'], ['workspaceTools.terminal', '终端'], 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 74de864560..d7098968a9 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,3 +1,4 @@ +import { StartupRevealPreference } from '../services/StartupRevealPreference'; import { StartupBrandReveal } from './components/StartupBrandReveal'; import { AppRootPresentation } from './components/AppRootPresentation'; import { WatchProvisionCard } from './components/WatchProvisionCard'; @@ -7,7 +8,7 @@ import { AppRootRuntime } from './runtime/AppRootRuntime'; @Entry @ComponentV2 struct AppRoot { - @Local showStartupBrand: boolean = true; + @Local showStartupBrand: boolean = false; private readonly hostAdapter: ArkUiAppRootHostAdapter = new ArkUiAppRootHostAdapter(); private readonly runtime: AppRootRuntime = new AppRootRuntime(this.hostAdapter); @@ -16,6 +17,7 @@ struct AppRoot { if (!hostContext) { throw new Error('App root host context is unavailable.'); } + this.showStartupBrand = StartupRevealPreference.claim(hostContext); this.hostAdapter.attach(hostContext, this.getUIContext()); await this.runtime.aboutToAppear(); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppShell.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppShell.ets index 272adb6561..ef319b5dfd 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppShell.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppShell.ets @@ -105,7 +105,7 @@ export struct AppShell { x: (this.allowSidebar && this.shellState.showSidebar) ? 0.985 : 1, y: (this.allowSidebar && this.shellState.showSidebar) ? 0.992 : 1, centerX: '0%', - centerY: '50%' + centerY: MobileDesignGeometry.conversationHeaderHeight / 2 }) .translate({ x: (this.allowSidebar && this.shellState.showSidebar) ? this.sidebarWidth() : 0 }) .animation({ diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets index 12e24926fb..8a133e371f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets @@ -1,8 +1,9 @@ +import { SidebarToolsFooter } from './SidebarToolsFooter'; import { SavedRuntimeConnection } from '../../model/RemoteModels'; import { WorkspaceToolsIntent } from '../state/WorkspaceToolsState'; import { MobileDesignGeometry, MobileDesignTypography } from '../../generated/MobileDesignTokens'; import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { CARD, INK, LINE, PAGE_BG, PAGE_BG_FADE, SHADOW_FAINT, SHADOW_SUBTLE, SOFT, SUBTLE, TRANSPARENT } from './Theme'; +import { CARD, INK, LINE, PAGE_BG, PAGE_BG_FADE, SHADOW_FAINT, SOFT, SUBTLE, TRANSPARENT } from './Theme'; import { SidebarToggleButton } from './SidebarToggleButton'; import { SidebarGlyph } from './SidebarGlyphs'; import { SidebarConnectionActionPolicy } from '../policy/SidebarConnectionActionPolicy'; @@ -116,7 +117,7 @@ export struct AppSidebar { } .width('100%') .height('100%') - .padding({ left: 20, right: 20, top: 4, bottom: 16 }) + .padding({ left: 20, right: 20, top: 0, bottom: 16 }) .backgroundColor(PAGE_BG) } @@ -179,7 +180,7 @@ export struct AppSidebar { } } .width('100%') - .height(50) + .height(MobileDesignGeometry.conversationHeaderHeight) .alignItems(VerticalAlign.Center) if (this.showSearch) { @@ -239,50 +240,11 @@ export struct AppSidebar { @Builder private PrimaryNavigationFooter() { - Row() { - Button() { - Row({ space: 8 }) { - SidebarGlyph({ kind: 'edit' }) - Text(RemoteI18n.t('workspaceTools.title')) - .fontSize(MobileDesignTypography.titleMedium.size) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - } - } - .constraintSize({ minWidth: 128, maxWidth: 180 }) - .height(44) - .padding({ left: 14, right: 14 }) - .backgroundColor(CARD) - .border({ width: 0.5, color: LINE }) - .borderRadius(22) - .shadow({ radius: 24, color: SHADOW_SUBTLE, offsetY: 7 }) - .enabled(this.connectionState === 'connected') - .opacity(this.connectionState === 'connected' ? 1 : 0.45) - .accessibilityText(RemoteI18n.t('workspaceTools.title')) - .stateEffect(true) - .onClick(() => { this.requestDeviceTools(); }) - - Blank() - - Button() { - SidebarGlyph({ kind: 'settings' }) - } - .width(44) - .height(44) - .padding(0) - .backgroundColor(CARD) - .borderRadius(22) - .shadow({ radius: 24, color: SHADOW_SUBTLE, offsetY: 7 }) - .stateEffect(true) - .onClick(() => { - this.onOpenSettings(); - }) - } - .width('100%') - .height(56) - .padding({ left: 12 }) - .alignItems(VerticalAlign.Center) - .zIndex(2) + SidebarToolsFooter({ + toolsEnabled: this.connectionState === 'connected', + onOpenTools: () => this.requestDeviceTools(), + onOpenSettings: () => this.onOpenSettings() + }) } @Builder diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationHeader.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationHeader.ets index 00e56dc862..e392009197 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationHeader.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationHeader.ets @@ -59,8 +59,7 @@ export struct ConversationHeader { this.TrailingControl() } .width('100%') - .height(this.hasSubtitle() ? MobileDesignGeometry.conversationHeaderHeight : - MobileDesignGeometry.conversationHeaderCompactHeight) + .height(MobileDesignGeometry.conversationHeaderHeight) .alignItems(VerticalAlign.Center) .padding({ left: MobileDesignGeometry.contentGutter, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarDeviceGroup.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarDeviceGroup.ets index 775f48ff19..7a67cd4e16 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarDeviceGroup.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarDeviceGroup.ets @@ -22,7 +22,6 @@ import { MUTED, SOFT, STATUS_SUCCESS, - SUBTLE, TRANSPARENT, } from './Theme'; @@ -338,10 +337,8 @@ export struct SidebarDeviceGroup { @Param selectedSessionId: string = ''; @Param runningSessionId: string = ''; @Param query: string = ''; - @Param showHeader: boolean = false; @Param isActive: boolean = false; @Param supportsHarnessProfiles: boolean = false; - @Event onToggle: () => void = () => {}; @Event onRetry: () => void = () => {}; @Event onOpenSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; @Event onSessionActions: (session: RemoteSession) => void = (_session: RemoteSession) => {}; @@ -356,112 +353,54 @@ export struct SidebarDeviceGroup { build() { Column() { - if (this.showHeader) { - this.DeviceHeader() - } - if (this.shouldShowBody()) { - if (this.device.status === 'failed') { - this.FailedRow() - } else if (this.workspaceEntries().length === 0) { - if (this.device.status === 'ready') { - this.EmptyWorkspaces() - } else if (this.device.status === 'cached') { - this.EmptyWorkspaces() - } else { - this.LoadingRow() - } + if (this.device.status === 'failed') { + this.FailedRow() + } else if (this.workspaceEntries().length === 0) { + if (this.device.status === 'ready') { + this.EmptyWorkspaces() + } else if (this.device.status === 'cached') { + this.EmptyWorkspaces() } else { - ForEach(this.visibleWorkspaceEntries(), (entry: RecentWorkspaceEntry) => { - SidebarWorkspaceGroup({ - connectionId: entry.remoteConnectionId || '', - sshHost: entry.remoteSshHost || '', - onWorkspaceTools: this.onWorkspaceTools, - deviceId: this.device.deviceId, - path: entry.path, - name: entry.remoteSshHost?.trim() ? `${entry.name} · ${entry.remoteSshHost.trim()}` : entry.name, - sessions: this.sessionsFor(entry), - selectedSessionId: this.selectedSessionId, - runningSessionId: this.runningSessionId, - currentWorkspace: this.isCurrentWorkspace(entry), - activeDevice: this.isActive, - supportsHarnessProfiles: this.supportsHarnessProfiles, - bodyIndent: this.bodyIndent(), - expanded: this.directoryState.workspaceExpanded(this.device.deviceId, entry.path, entry.remoteConnectionId, entry.remoteSshHost), - loadStatus: this.directoryState.workspaceStatus(this.device.deviceId, entry.path, entry.remoteConnectionId, entry.remoteSshHost), - hasLoadedSessions: this.directoryState.workspaceHasLoadedSessions(this.device.deviceId, entry.path, entry.remoteConnectionId, entry.remoteSshHost), - onOpenWorkspace: this.onOpenWorkspace, - onExpandWorkspace: (path: string): Promise => this.onExpandWorkspace(path, entry.remoteConnectionId, entry.remoteSshHost), - onExpandedChange: (path: string, expanded: boolean): void => this.onWorkspaceExpandedChange(path, expanded, entry.remoteConnectionId, entry.remoteSshHost), - onCreateInWorkspace: this.onCreateInWorkspace, - onOpenSession: this.onOpenSession, - onSessionActions: this.onSessionActions - }) - }, (entry: RecentWorkspaceEntry): string => - `${this.device.deviceId}:${remoteWorkspaceKey(entry)}`) - if (this.hiddenWorkspaceCount() > 0) { - this.MoreWorkspacesRow() - } - if (this.device.status === 'loading') { - this.LoadingRow() - } + this.LoadingRow() + } + } else { + ForEach(this.visibleWorkspaceEntries(), (entry: RecentWorkspaceEntry) => { + SidebarWorkspaceGroup({ + connectionId: entry.remoteConnectionId || '', + sshHost: entry.remoteSshHost || '', + onWorkspaceTools: this.onWorkspaceTools, + deviceId: this.device.deviceId, + path: entry.path, + name: entry.remoteSshHost?.trim() ? `${entry.name} · ${entry.remoteSshHost.trim()}` : entry.name, + sessions: this.sessionsFor(entry), + selectedSessionId: this.selectedSessionId, + runningSessionId: this.runningSessionId, + currentWorkspace: this.isCurrentWorkspace(entry), + activeDevice: this.isActive, + supportsHarnessProfiles: this.supportsHarnessProfiles, + bodyIndent: 10, + expanded: this.directoryState.workspaceExpanded(this.device.deviceId, entry.path, entry.remoteConnectionId, entry.remoteSshHost), + loadStatus: this.directoryState.workspaceStatus(this.device.deviceId, entry.path, entry.remoteConnectionId, entry.remoteSshHost), + hasLoadedSessions: this.directoryState.workspaceHasLoadedSessions(this.device.deviceId, entry.path, entry.remoteConnectionId, entry.remoteSshHost), + onOpenWorkspace: this.onOpenWorkspace, + onExpandWorkspace: (path: string): Promise => this.onExpandWorkspace(path, entry.remoteConnectionId, entry.remoteSshHost), + onExpandedChange: (path: string, expanded: boolean): void => this.onWorkspaceExpandedChange(path, expanded, entry.remoteConnectionId, entry.remoteSshHost), + onCreateInWorkspace: this.onCreateInWorkspace, + onOpenSession: this.onOpenSession, + onSessionActions: this.onSessionActions + }) + }, (entry: RecentWorkspaceEntry): string => + `${this.device.deviceId}:${remoteWorkspaceKey(entry)}`) + if (this.hiddenWorkspaceCount() > 0) { + this.MoreWorkspacesRow() + } + if (this.device.status === 'loading') { + this.LoadingRow() } } } .width('100%') .alignItems(HorizontalAlign.Start) - .margin({ bottom: this.showHeader ? 8 : 0 }) - } - - @Builder - private DeviceHeader() { - Row({ space: 10 }) { - SymbolGlyph($r('sys.symbol.desktop')) - .fontSize(19) - .fontColor([this.device.online ? INK : SUBTLE]) - .width(21) - .height(21) - .hitTestBehavior(HitTestMode.None) - Text(this.device.deviceName) - .fontSize(MobileDesignTypography.bodyMedium.size) - .fontWeight(this.isActive ? FontWeight.Bold : FontWeight.Regular) - .fontColor(this.device.online ? INK : SUBTLE) - .layoutWeight(1) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .wordBreak(WordBreak.BREAK_ALL) - .hitTestBehavior(HitTestMode.None) - if (!this.device.online) { - Text(RemoteI18n.t('sidebar.deviceOffline')) - .fontSize(MobileDesignTypography.labelSmall.size) - .fontColor(SUBTLE) - .hitTestBehavior(HitTestMode.None) - } - if (this.device.status === 'loading') { - LoadingProgress() - .width(14) - .height(14) - .color(MUTED) - .hitTestBehavior(HitTestMode.None) - } - Stack({ alignContent: Alignment.Center }) { - SymbolGlyph(this.device.expanded ? - $r('sys.symbol.chevron_down') : $r('sys.symbol.chevron_right')) - .fontSize(13) - .fontColor([MUTED]) - .hitTestBehavior(HitTestMode.None) - } - .width(16) - .height(16) - .hitTestBehavior(HitTestMode.None) - } - .width('100%') - .height(46) - .padding({ left: 10, right: 6 }) - .alignItems(VerticalAlign.Center) - .opacity(this.device.online ? 1 : 0.7) - .onClick(() => { - this.onToggle(); - }) } @Builder @@ -477,7 +416,7 @@ export struct SidebarDeviceGroup { } .width('100%') .height(40) - .padding({ left: this.bodyIndent(), right: 10 }) + .padding({ left: 10, right: 10 }) .alignItems(VerticalAlign.Center) } @@ -498,7 +437,7 @@ export struct SidebarDeviceGroup { } .width('100%') .height(40) - .padding({ left: this.bodyIndent(), right: 10 }) + .padding({ left: 10, right: 10 }) .alignItems(VerticalAlign.Center) } @@ -508,7 +447,7 @@ export struct SidebarDeviceGroup { .fontSize(MobileDesignTypography.bodyMedium.size) .fontColor(MUTED) .width('100%') - .padding({ left: this.bodyIndent(), top: 6, bottom: 6 }) + .padding({ left: 10, top: 6, bottom: 6 }) } @Builder @@ -523,7 +462,7 @@ export struct SidebarDeviceGroup { } .width('100%') .height(40) - .padding({ left: this.bodyIndent() }) + .padding({ left: 10 }) .alignItems(VerticalAlign.Center) .onClick(() => { this.visibleWorkspaceCount = SidebarDirectoryPreviewPolicy.nextVisibleCount( @@ -533,21 +472,6 @@ export struct SidebarDeviceGroup { }) } - private shouldShowBody(): boolean { - if (!this.showHeader) { - return true; - } - if (!this.device.online && this.device.workspaces.length === 0 && - this.recentWorkspaces.length === 0) { - return false; - } - return this.device.expanded; - } - - private bodyIndent(): number { - return this.showHeader ? 20 : 10; - } - private projection(): SessionListProjection { const inputs: SessionListInputs = { sessions: this.sessions, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarToolsFooter.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarToolsFooter.ets new file mode 100644 index 0000000000..07c665a404 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarToolsFooter.ets @@ -0,0 +1,60 @@ +import { MobileDesignTypography } from '../../generated/MobileDesignTokens'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { CARD, INK, LINE } from './Theme'; + +/** Pure navigation controls reused in compact and wide sidebars. */ +@ComponentV2 +export struct SidebarToolsFooter { + @Param toolsEnabled: boolean = false; + @Event onOpenTools: () => void = () => {}; + @Event onOpenSettings: () => void = () => {}; + + build() { + Row() { + Button() { + Row({ space: 8 }) { + Stack() { + SymbolGlyph($r('sys.symbol.wrench_and_screwdriver')) + .fontSize(20) + .fontColor([INK]) + }.width(24).height(24) + Text(RemoteI18n.t('workspaceTools.title')) + .fontSize(MobileDesignTypography.labelLarge.size) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .maxLines(1) + } + } + .constraintSize({ minWidth: 104 }) + .height(48) + .padding({ left: 14, right: 14 }) + .backgroundColor(CARD) + .border({ width: 0.5, color: LINE }) + .borderRadius(24) + .enabled(this.toolsEnabled) + .opacity(this.toolsEnabled ? 1 : 0.45) + .accessibilityText(RemoteI18n.t('workspaceTools.title')) + .stateEffect(true) + .onClick(() => this.onOpenTools()) + Blank() + Button() { + SymbolGlyph($r('sys.symbol.gearshape')) + .fontSize(20) + .fontColor([INK]) + } + .width(48) + .height(48) + .padding(0) + .backgroundColor(CARD) + .borderRadius(24) + .accessibilityText(RemoteI18n.t('settings.title')) + .stateEffect(true) + .onClick(() => this.onOpenSettings()) + } + .width('100%') + .height(56) + .padding({ left: 12 }) + .alignItems(VerticalAlign.Center) + .zIndex(2) + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspaceSection.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspaceSection.ets index 7765902524..10ffdabff2 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspaceSection.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspaceSection.ets @@ -16,12 +16,9 @@ import { SettingsSheetKind } from '../policy/SettingsPlacementPolicy'; import { AdaptiveSheetOptions } from './AdaptiveSheetOptions'; -import { STATUS_SUCCESS, STATUS_DANGER, INK, MUTED, SUBTLE } from './Theme'; +import { STATUS_SUCCESS, STATUS_DANGER, INK, MUTED, SUBTLE, SOFT, TRANSPARENT } from './Theme'; import { SidebarDeviceGroup } from './SidebarDeviceGroup'; -import { - SidebarChevronIcon, - SidebarDeviceIcon -} from './SidebarNavigationIcons'; +import { SidebarDeviceIcon } from './SidebarNavigationIcons'; import { SidebarWorkspacePicker } from './SidebarWorkspacePicker'; import { ConnectionStatusPresenter } from '../../services/ConnectionStatusPresenter'; import { RemoteUiState } from '../../services/RemoteUiState'; @@ -95,10 +92,6 @@ export struct SidebarWorkspaceSection { if (this.deviceEntries().length > 0) { ForEach(this.visibleDeviceEntries(), (entry: DeviceDirectoryEntry) => { this.DeviceRow(entry) - if (this.isActiveDevice(entry.deviceId) && - (this.connectionState === 'reconnecting' || this.connectionState === 'failed' || this.connectionState === 'disconnected')) { - this.ConnectionRecoveryRow() - } }, (entry: DeviceDirectoryEntry): string => this.deviceKey(entry)) if (this.visibleDeviceCount < this.deviceEntries().length) { this.MoreDevicesRow() @@ -164,13 +157,13 @@ export struct SidebarWorkspaceSection { SidebarDeviceIcon({ deviceName: entry.deviceName, iconSize: 24, - tint: entry.online ? INK : SUBTLE + tint: INK }) .hitTestBehavior(HitTestMode.None) Text(entry.deviceName) .fontSize(MobileDesignTypography.bodyMedium.size) - .fontWeight(entry.deviceId.length > 0 && this.controlTargetDeviceId.trim() === entry.deviceId ? FontWeight.Bold : FontWeight.Regular) - .fontColor(entry.online ? INK : SUBTLE) + .fontWeight(this.isSelected(entry) ? FontWeight.Bold : FontWeight.Regular) + .fontColor(INK) .layoutWeight(1) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) @@ -190,33 +183,30 @@ export struct SidebarWorkspaceSection { .borderRadius(4) .hitTestBehavior(HitTestMode.None) } - Stack({ alignContent: Alignment.Center }) { - SidebarChevronIcon({ - expanded: this.isSelected(entry), - iconSize: 14, - tint: entry.online ? INK : SUBTLE - }) - .hitTestBehavior(HitTestMode.None) - } - .width(20) - .height(24) - .hitTestBehavior(HitTestMode.None) } .width('100%') .height(52) .padding({ left: 8, right: 4 }) .alignItems(VerticalAlign.Center) - .opacity(entry.online ? 1 : 0.58) + .backgroundColor(this.isSelected(entry) ? SOFT : TRANSPARENT) + .borderRadius(10) .accessibilityText(`${entry.deviceName}, ${this.isActiveDevice(entry.deviceId) ? ConnectionStatusPresenter.label(this.connectionState) : RemoteI18n.t(entry.online ? 'sidebar.deviceOnline' : 'sidebar.deviceOffline')}`) - .enabled(this.canUseDevice(entry)) + .enabled(this.canUseDevice(entry) || this.canRetryDevice(entry)) .onClick(() => { - if (this.canUseDevice(entry)) { + if (this.canRetryDevice(entry)) { + this.onReconnect(); + } else if (this.canUseDevice(entry)) { this.onSelectDevice(entry.deviceId); } }) } + private canRetryDevice(entry: DeviceDirectoryEntry): boolean { + return this.isActiveDevice(entry.deviceId) && + (this.connectionState === 'failed' || this.connectionState === 'disconnected'); + } + private deviceHealthColor(entry: DeviceDirectoryEntry): ResourceColor { if (!this.isActiveDevice(entry.deviceId)) return entry.online ? STATUS_SUCCESS : SUBTLE; if (this.connectionState === 'connected') return STATUS_SUCCESS; @@ -224,23 +214,6 @@ export struct SidebarWorkspaceSection { return MUTED; } - @Builder - private ConnectionRecoveryRow() { - Row() { - Text(ConnectionStatusPresenter.label(this.connectionState)) - .fontSize(MobileDesignTypography.labelSmall.size) - .fontColor(MUTED) - .layoutWeight(1) - Stack({ alignContent: Alignment.Center }) { - SymbolGlyph($r('sys.symbol.arrow_clockwise')).fontSize(17).fontColor([MUTED]) - } - .width(44).height(44) - .accessibilityText(RemoteI18n.t('common.retry')) - .onClick(() => this.onReconnect()) - } - .width('100%').height(44).padding({ left: 10, right: 6 }) - } - @Builder private WorkspaceSection(entry: DeviceDirectoryEntry) { Column() { @@ -290,7 +263,6 @@ export struct SidebarWorkspaceSection { selectedSessionId: this.selectedSessionIdFor(entry), runningSessionId: this.isActiveDevice(entry.deviceId) ? this.runningSessionId : '', query: this.query, - showHeader: false, isActive: this.isActiveDevice(entry.deviceId), supportsHarnessProfiles: this.supportsHarnessProfiles, onRetry: () => { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/DeviceSelectorPreview.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/DeviceSelectorPreview.ets new file mode 100644 index 0000000000..95706d2db2 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/DeviceSelectorPreview.ets @@ -0,0 +1,38 @@ +import { SidebarWorkspaceSection } from '../components/SidebarWorkspaceSection'; +import { DeviceDirectoryState, emptyDeviceDirectoryEntry } from '../state/DeviceDirectoryState'; +import { PAGE_BG } from '../components/Theme'; + +/** Isolated production-sidebar fixture; never reads credentials or opens a transport. */ +@ComponentV2 +export struct DeviceSelectorPreview { + @Local directory: DeviceDirectoryState = this.makeDirectory(); + + private makeDirectory(): DeviceDirectoryState { + const state = new DeviceDirectoryState(); + const first = emptyDeviceDirectoryEntry('preview-mac', 'MacBook Pro', true); + first.status = 'ready'; + first.workspaces = [{ path: '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/workspace/openbitfun', name: 'OpenBitFun', lastOpened: '', workspaceKind: 'normal' }]; + const second = emptyDeviceDirectoryEntry('preview-linux', 'Linux Desktop', true); + second.status = 'ready'; + second.workspaces = [{ path: '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/workspace/demo', name: 'Demo', lastOpened: '', workspaceKind: 'normal' }]; + const offline = emptyDeviceDirectoryEntry('preview-offline', 'Offline Desktop', false); + state.replace([first, second, offline]); + state.select(first.deviceId); + return state; + } + + build() { + Scroll() { + SidebarWorkspaceSection({ + directoryState: this.directory, + onSelectDevice: (id: string): void => { this.directory.select(id); } + }) + .width('100%') + .constraintSize({ maxWidth: 360 }) + .padding(16) + } + .align(Alignment.TopStart) + .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 0cf28e72f2..ed7d6d3af6 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,3 +1,4 @@ +import { DeviceSelectorPreview } from './DeviceSelectorPreview'; import { WelcomeHome } from '../components/WelcomeHome'; import { InteractionMailboxPreview } from './InteractionMailboxPreview'; import { MobileDesignGeometry, MobileDesignTypography } from '../../generated/MobileDesignTokens'; @@ -32,7 +33,9 @@ struct MobileDesignGallery { } build() { - if (AppStorage.get('scenarioId') === 'welcome-home') { + if ((AppStorage.get('scenarioId') || '').startsWith('device-selector')) { + DeviceSelectorPreview() + } else if (AppStorage.get('scenarioId') === 'welcome-home') { WelcomeHome() } else if ((AppStorage.get('scenarioId') || '').startsWith('interaction-mailbox')) { InteractionMailboxPreview() 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 17304f914c..9732e1b224 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 @@ -252,11 +252,9 @@ export class AppRootRuntime extends AppRootRuntimeComposition { async selectDirectoryDevice(deviceId: string): Promise { const selectionVersion = ++this.directorySelectionVersion; - // Selecting an already disclosed device is a local collapse. The remote - // control target remains connected; only its workspace tree is hidden. - // Bumping the version first also prevents an earlier async selection from - // reopening the row after this second tap. - if (this.deviceDirectoryState.collapseIfSelected(deviceId)) { + // Re-selecting the current context never hides its workspace panel. + if (this.deviceDirectoryState.selectedDeviceId === deviceId && + this.isConnectedDirectoryTarget(deviceId)) { return; } await this.deviceDirectoryViewModel.selectDevice(deviceId); @@ -276,8 +274,7 @@ export class AppRootRuntime extends AppRootRuntimeComposition { if (this.appShellState.wideLayout) { await this.enterRemoteSurfaceWide(); } else { - // A device row is also the disclosure control for its workspaces. Keep - // the compact drawer visible so selecting it can reveal that content; + // Keep the compact drawer visible to show the selected workspace panel; // entering Remote from any other compact entry point still dismisses // the drawer through the method's default behavior. await this.enterRemoteSurfaceCompact(false); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/DeviceDirectoryState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/DeviceDirectoryState.ets index 85da1fb391..2b75ee21b0 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/DeviceDirectoryState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/DeviceDirectoryState.ets @@ -25,7 +25,6 @@ export class DeviceDirectoryEntry { @Trace deviceId: string = ''; @Trace deviceName: string = ''; @Trace online: boolean = false; - @Trace expanded: boolean = false; @Trace status: DeviceDirectoryStatus = 'idle'; @Trace errorText: string = ''; @Trace workspaces: RecentWorkspaceEntry[] = []; @@ -61,15 +60,6 @@ export class DeviceDirectoryState { this.selectedDeviceId = deviceId.trim(); } - /** Collapses the disclosed device without changing the live control target. */ - collapseIfSelected(deviceId: string): boolean { - if (this.selectedDeviceId !== deviceId.trim()) { - return false; - } - this.selectedDeviceId = ''; - return true; - } - find(deviceId: string): DeviceDirectoryEntry | undefined { return this.devices.find((item: DeviceDirectoryEntry): boolean => item.deviceId === deviceId); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/DeviceDirectoryViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/DeviceDirectoryViewModel.ets index d38345f175..6d832b9963 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/DeviceDirectoryViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/DeviceDirectoryViewModel.ets @@ -256,20 +256,17 @@ export class DeviceDirectoryViewModel { if (existing) { existing.deviceName = device.deviceName || existing.deviceName; if (existing.online && !online) { - existing.expanded = false; this.inFlight.delete(device.deviceId); if (existing.status === 'loading') existing.status = 'idle'; } existing.online = online; return existing; } - const isActive = device.deviceId === activeId && online; const created = emptyDeviceDirectoryEntry( device.deviceId, device.deviceName || device.deviceId, online ); - created.expanded = isActive; return created; }); this.state.replace(next); @@ -277,10 +274,10 @@ export class DeviceDirectoryViewModel { (this.state.selectedDeviceId === activeId && activeId.length > 0 && activeConnected)) { return; } - // A live control target is transport state, not a user selection in the - // directory. Expanding it here made a freshly signed-in sidebar show a - // workspace loader before the user had chosen any device. - this.state.select(''); + // Restore the persisted control target without selecting a different device + // merely because it happens to be online. + this.state.select(activeId); + } async hydrateKnownDevices(): Promise { @@ -295,7 +292,7 @@ export class DeviceDirectoryViewModel { const entry = this.state.find(target); if (!entry) { // A QR-paired desktop is projected into the sidebar without belonging to - // the account directory. It is still a real selectable disclosure row + // the account directory. It is still a real selectable device row // while its control link is live; accepting only directory entries made // that row render normally but ignore every tap. if (target.length > 0 && target === this.hooks.activeDeviceId().trim() && diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/StartupRevealPreference.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/StartupRevealPreference.ets new file mode 100644 index 0000000000..39d56822e2 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/StartupRevealPreference.ets @@ -0,0 +1,23 @@ +import { preferences } from '@kit.ArkData'; +import { RemoteLogger } from './RemoteLogger'; + +/** Installation-local presentation state, independent of accounts and app versions. */ +export class StartupRevealPreference { + private static claimed: boolean = false; + + static claim(context: Context): boolean { + if (StartupRevealPreference.claimed) return false; + StartupRevealPreference.claimed = true; + try { + const store = preferences.getPreferencesSync(context, { name: 'startup_presentation' }); + if (store.getSync('brand_reveal_shown', false) === true) return false; + // Persist before playback so interrupted launches do not replay the reveal. + store.putSync('brand_reveal_shown', true); + store.flush().catch(() => RemoteLogger.warn('Startup reveal preference could not be persisted.')); + return true; + } catch (_err) { + RemoteLogger.warn('Startup reveal preference unavailable; skipping reveal.'); + return false; + } + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets index 6b8a2e20f2..206bb7a900 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets @@ -623,7 +623,7 @@ export default function conversationPresentationUnitTest() { expect(RemoteI18n.t('sidebar.newChat')).assertEqual('聊天'); expect(RemoteI18n.t('sidebar.connectDesktop')).assertEqual('连接电脑'); expect(RemoteI18n.t('sidebar.scanToConnect')).assertEqual('扫码连接电脑'); - expect(RemoteI18n.t('sidebar.signInOpenBitFunAccount')).assertEqual('使用 GitHub 登录'); + expect(RemoteI18n.t('sidebar.signInOpenBitFunAccount')).assertEqual('使用邮箱或 GitHub 登录'); expect(RemoteI18n.t('connect.chooseConnectionTitle')).assertEqual('连接电脑'); expect(RemoteI18n.t('sidebar.local')).assertEqual('sidebar.local'); expect(RemoteI18n.t('sidebar.code')).assertEqual('sidebar.code'); diff --git a/src/apps/mobile/harmonyos/entry/src/test/DeviceDirectoryUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/DeviceDirectoryUnit.test.ets index 9a09c75de8..4585939fe8 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/DeviceDirectoryUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/DeviceDirectoryUnit.test.ets @@ -185,13 +185,13 @@ export default function deviceDirectoryUnitTest() { }); describe('DeviceDirectoryState', () => { - it('collapses only the device that is currently disclosed', 0, () => { + it('reselects a device without clearing the workspace selection', 0, () => { const state = new DeviceDirectoryState(); state.select('desk-a'); - expect(state.collapseIfSelected('desk-b')).assertFalse(); + state.select('desk-a'); expect(state.selectedDeviceId).assertEqual('desk-a'); - expect(state.collapseIfSelected('desk-a')).assertTrue(); - expect(state.selectedDeviceId).assertEqual(''); + state.select('desk-b'); + expect(state.selectedDeviceId).assertEqual('desk-b'); }); }); @@ -284,7 +284,7 @@ export default function deviceDirectoryUnitTest() { { deviceId: 'desk-other', deviceName: 'Other', online: false } ]); expect(state.find('desk-live')?.online).assertTrue(); - expect(state.selectedDeviceId).assertEqual(''); + expect(state.selectedDeviceId).assertEqual('desk-live'); // The override must not outlive the link it is based on, or a device that // really did go away keeps a lit row forever. expect(state.find('desk-other')?.online).assertFalse(); @@ -293,7 +293,7 @@ export default function deviceDirectoryUnitTest() { { deviceId: 'desk-live', deviceName: 'Studio', online: false } ]); expect(state.find('desk-live')?.online).assertFalse(); - expect(state.selectedDeviceId).assertEqual(''); + expect(state.selectedDeviceId).assertEqual('desk-live'); }); it('keeps an explicit device selection across syncDevices', 0, async () => { @@ -315,7 +315,7 @@ export default function deviceDirectoryUnitTest() { ]); expect(state.workspaceExpanded('desk-other', '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/workspace/project')).assertTrue(); expect(state.workspaceStatus('desk-other', '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/workspace/project/')).assertEqual('loading'); - expect(state.selectedDeviceId).assertEqual(''); + expect(state.selectedDeviceId).assertEqual('desk-active'); await viewModel.selectDevice('desk-other'); expect(state.selectedDeviceId).assertEqual('desk-other'); viewModel.syncDevices([ diff --git a/src/apps/mobile/harmonyos/tools/tests/device-directory-lifecycle.test.cjs b/src/apps/mobile/harmonyos/tools/tests/device-directory-lifecycle.test.cjs index 3706be6f2e..4090a357b8 100644 --- a/src/apps/mobile/harmonyos/tools/tests/device-directory-lifecycle.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/device-directory-lifecycle.test.cjs @@ -177,3 +177,14 @@ test('old account manual refresh cannot fail or release replacement refresh', as current.resolve(); await second; assert.equal(f.state.refreshing, false); }); + + +test('directory restores the active target and refresh preserves an explicit selection', () => { + const f = fixture(); + f.active('desktop'); + f.vm.syncDevices([{ deviceId: 'desktop', online: true }, { deviceId: 'other', online: true }]); + assert.equal(f.state.selectedDeviceId, 'desktop'); + f.state.select('other'); + f.vm.syncDevices([{ deviceId: 'desktop', online: true }, { deviceId: 'other', online: false }]); + assert.equal(f.state.selectedDeviceId, 'other'); +}); diff --git a/src/apps/mobile/harmonyos/tools/tests/startup-reveal.test.cjs b/src/apps/mobile/harmonyos/tools/tests/startup-reveal.test.cjs new file mode 100644 index 0000000000..af9357564c --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/startup-reveal.test.cjs @@ -0,0 +1,26 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); +const source = fs.readFileSync(path.join(__dirname, '../../entry/src/main/ets/services/StartupRevealPreference.ets'), 'utf8'); +function load(values) { + const exports = {}; + const js = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText; + new Function('require', 'exports', js)(name => name === '@kit.ArkData' ? { + preferences: { getPreferencesSync: () => ({ + getSync: (key, fallback) => values.get(key) ?? fallback, + putSync: (key, value) => values.set(key, value), + flush: async () => {}, + }) }, + } : { RemoteLogger: { warn() {} } }, exports); + return exports.StartupRevealPreference; +} +test('first appearance claims persistently before an interrupted animation, including a new process', () => { + const disk = new Map(); + const firstProcess = load(disk); + assert.equal(firstProcess.claim({}), true); + assert.equal(firstProcess.claim({}), false); + assert.equal(load(disk).claim({}), false); + assert.equal(load(new Map()).claim({}), true); +}); diff --git a/src/apps/mobile/ios/OpenBitFun.xcodeproj/project.pbxproj b/src/apps/mobile/ios/OpenBitFun.xcodeproj/project.pbxproj index 98bd389dbf..a54bb4d634 100644 --- a/src/apps/mobile/ios/OpenBitFun.xcodeproj/project.pbxproj +++ b/src/apps/mobile/ios/OpenBitFun.xcodeproj/project.pbxproj @@ -7,6 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + A9F000000000000000000003 = {isa = PBXBuildFile; fileRef = B9F000000000000000000003; }; + A9F000000000000000000002 = {isa = PBXBuildFile; fileRef = B9F000000000000000000002; }; + A9F000000000000000000001 /* SidebarToolsFooter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9F000000000000000000001; }; A10000000000000000000039 = {isa = PBXBuildFile; fileRef = B10000000000000000000039; }; A90000000000000000000001 /* StartupBrandReveal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B90000000000000000000001; }; A10000000000000000000001 /* OpenBitFunApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000001 /* OpenBitFunApp.swift */; }; @@ -59,6 +62,9 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + B9F000000000000000000003 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarDeviceRow.swift; sourceTree = ""; }; + B9F000000000000000000002 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenBitFun/Infrastructure/StartupRevealPreference.swift; sourceTree = SOURCE_ROOT; }; + B9F000000000000000000001 /* SidebarToolsFooter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarToolsFooter.swift; sourceTree = ""; }; B10000000000000000000039 = {isa = PBXFileReference; lastKnownFileType = folder; path = ../../../shared/terminal/webview/generated; sourceTree = SOURCE_ROOT; }; B90000000000000000000001 /* StartupBrandReveal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenBitFun/Features/Shell/StartupBrandReveal.swift; sourceTree = SOURCE_ROOT; }; B10000000000000000000000 /* OpenBitFun.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OpenBitFun.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -116,7 +122,7 @@ /* Begin PBXGroup section */ D10000000000000000000000 = {isa = PBXGroup; children = (D10000000000000000000001 /* OpenBitFun */, D20000000000000000000001 /* OpenBitFunUITests */, D10000000000000000000009 /* Products */); sourceTree = ""; }; - D10000000000000000000001 /* OpenBitFun */ = {isa = PBXGroup; children = (B91000000000000000000001, B91000000000000000000002, D10000000000000000000002 /* App */, D10000000000000000000016 /* Presentation */, D10000000000000000000003 /* Features */, D10000000000000000000008 /* Infrastructure */, D10000000000000000000011 /* Resources */, B10000000000000000000009 /* Resources.xcassets */, B10000000000000000000011 /* OpenBitFunMobileCore.xcframework */, B10000000000000000000012 /* libsqlite3.tbd */); path = OpenBitFun; sourceTree = ""; }; + D10000000000000000000001 /* OpenBitFun */ = {isa = PBXGroup; children = (B9F000000000000000000002, B91000000000000000000001, B91000000000000000000002, D10000000000000000000002 /* App */, D10000000000000000000016 /* Presentation */, D10000000000000000000003 /* Features */, D10000000000000000000008 /* Infrastructure */, D10000000000000000000011 /* Resources */, B10000000000000000000009 /* Resources.xcassets */, B10000000000000000000011 /* OpenBitFunMobileCore.xcframework */, B10000000000000000000012 /* libsqlite3.tbd */); path = OpenBitFun; sourceTree = ""; }; D10000000000000000000002 /* App */ = {isa = PBXGroup; children = (B10000000000000000000001 /* OpenBitFunApp.swift */, B10000000000000000000037 /* MobileLaunchConfiguration.swift */); path = App; sourceTree = ""; }; D10000000000000000000003 /* Features */ = {isa = PBXGroup; children = (D10000000000000000000004 /* Chat */, D10000000000000000000012 /* Remote */, D10000000000000000000013 /* Settings */, D10000000000000000000014 /* Pairing */, D10000000000000000000015 /* Account */, D10000000000000000000005 /* Shell */, D10000000000000000000010 /* DesignSystem */); path = Features; sourceTree = ""; }; D10000000000000000000012 /* Remote */ = {isa = PBXGroup; children = (B10000000000000000000031 /* RemoteHomeViews.swift */); path = Remote; sourceTree = ""; }; @@ -124,7 +130,7 @@ D10000000000000000000014 /* Pairing */ = {isa = PBXGroup; children = (B10000000000000000000033 /* PairingSheet.swift */); path = Pairing; sourceTree = ""; }; D10000000000000000000015 /* Account */ = {isa = PBXGroup; children = (B10000000000000000000035 /* AccountSettingsView.swift */); path = Account; sourceTree = ""; }; D10000000000000000000004 /* Chat */ = {isa = PBXGroup; children = (B90000000000000000000004 /* TimelineScrollController.swift */, B10000000000000000000030 /* ConversationHomeViews.swift */, B10000000000000000000005 /* ConversationHeader.swift */, B10000000000000000000006 /* ChatTimelineView.swift */, B10000000000000000000007 /* ComposerBar.swift */); path = Chat; sourceTree = ""; }; - D10000000000000000000005 /* Shell */ = {isa = PBXGroup; children = (B90000000000000000000001 /* StartupBrandReveal.swift */, B10000000000000000000003 /* OpenBitFunTheme.swift */, B10000000000000000000004 /* SidebarView.swift */, B10000000000000000000008 /* MobileShellView.swift */, B10000000000000000000019 /* SessionActionComponents.swift */, B10000000000000000000020 /* RemoteCreateSessionView.swift */, B10000000000000000000021 /* RemoteFilePreviewView.swift */, B10000000000000000000022 /* RemoteSettingsViews.swift */); path = Shell; sourceTree = ""; }; + D10000000000000000000005 /* Shell */ = {isa = PBXGroup; children = (B9F000000000000000000003, B9F000000000000000000001, B90000000000000000000001 /* StartupBrandReveal.swift */, B10000000000000000000003 /* OpenBitFunTheme.swift */, B10000000000000000000004 /* SidebarView.swift */, B10000000000000000000008 /* MobileShellView.swift */, B10000000000000000000019 /* SessionActionComponents.swift */, B10000000000000000000020 /* RemoteCreateSessionView.swift */, B10000000000000000000021 /* RemoteFilePreviewView.swift */, B10000000000000000000022 /* RemoteSettingsViews.swift */); path = Shell; sourceTree = ""; }; D10000000000000000000011 /* Resources */ = {isa = PBXGroup; children = (B10000000000000000000016 /* Localizable.xcstrings */); path = Resources; sourceTree = ""; }; D10000000000000000000016 /* Presentation */ = {isa = PBXGroup; children = (D10000000000000000000018 /* Models */); path = Presentation; sourceTree = ""; }; D10000000000000000000018 /* Models */ = {isa = PBXGroup; children = (B10000000000000000000036 /* MobilePresentationModels.swift */); path = Models; sourceTree = ""; }; @@ -148,7 +154,7 @@ C91000000000000000000001 = {isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = (); inputPaths = (); outputPaths = ("$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/MiniApps"); alwaysOutOfDate = 1; runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "export PATH=\"/opt/homebrew/bin:/usr/local/bin:$PATH\"\nnode \"$SRCROOT/../miniapps/generate.cjs\" ios \"$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/MiniApps\"\n"; }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - C10000000000000000000002 /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (A91000000000000000000001, A90000000000000000000003, A90000000000000000000004, A90000000000000000000002, A90000000000000000000001, A10000000000000000000041, A10000000000000000000040, A10000000000000000000001, A10000000000000000000002, A10000000000000000000003, A10000000000000000000004, A10000000000000000000005, A10000000000000000000006, A10000000000000000000007, A10000000000000000000008, A10000000000000000000010, A10000000000000000000013, A10000000000000000000014, A10000000000000000000015, A10000000000000000000017, A10000000000000000000018, A10000000000000000000019, A10000000000000000000020, A10000000000000000000021, A10000000000000000000022, A10000000000000000000024, A10000000000000000000025, A10000000000000000000026, A10000000000000000000027, A10000000000000000000028, A10000000000000000000029, A10000000000000000000030, A10000000000000000000031, A10000000000000000000032, A10000000000000000000033, A10000000000000000000034, A10000000000000000000035, A10000000000000000000036, A10000000000000000000037, A10000000000000000000038); runOnlyForDeploymentPostprocessing = 0; }; + C10000000000000000000002 /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (A9F000000000000000000003, A9F000000000000000000002, A9F000000000000000000001, A91000000000000000000001, A90000000000000000000003, A90000000000000000000004, A90000000000000000000002, A90000000000000000000001, A10000000000000000000041, A10000000000000000000040, A10000000000000000000001, A10000000000000000000002, A10000000000000000000003, A10000000000000000000004, A10000000000000000000005, A10000000000000000000006, A10000000000000000000007, A10000000000000000000008, A10000000000000000000010, A10000000000000000000013, A10000000000000000000014, A10000000000000000000015, A10000000000000000000017, A10000000000000000000018, A10000000000000000000019, A10000000000000000000020, A10000000000000000000021, A10000000000000000000022, A10000000000000000000024, A10000000000000000000025, A10000000000000000000026, A10000000000000000000027, A10000000000000000000028, A10000000000000000000029, A10000000000000000000030, A10000000000000000000031, A10000000000000000000032, A10000000000000000000033, A10000000000000000000034, A10000000000000000000035, A10000000000000000000036, A10000000000000000000037, A10000000000000000000038); runOnlyForDeploymentPostprocessing = 0; }; C20000000000000000000002 /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (A20000000000000000000001 /* RemoteCodeSessionSendUITests.swift in Sources */); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ diff --git a/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift b/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift index 7c6b179100..1764a19678 100644 --- a/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift +++ b/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift @@ -304,9 +304,9 @@ private extension MobileAppModel { let offlineWorkspace = MobileWorkspaceGroup(path: "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/office/project", name: "Office project", selected: false, sessions: [cachedSession], deviceKey: "preview-offline") let failedWorkspace = MobileWorkspaceGroup(path: "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/staging/project", name: "Staging", selected: false, sessions: [failedSession], deviceKey: "preview-mac") deviceDirectory = [ - MobileDeviceDirectoryEntry(id: "preview-desktop", name: "DESKTOP-KM3L4UI", online: true, expanded: true, status: "READY", error: nil, workspaces: [previewWorkspace], sessions: previewWorkspace.sessions), - MobileDeviceDirectoryEntry(id: "preview-mac", name: "Studio Mac", online: true, expanded: true, status: "FAILED", error: "REMOTE_UNAVAILABLE", workspaces: [failedWorkspace], sessions: [failedSession]), - MobileDeviceDirectoryEntry(id: "preview-offline", name: "Office PC", online: false, expanded: false, status: "READY", error: nil, workspaces: [offlineWorkspace], sessions: [cachedSession]) + MobileDeviceDirectoryEntry(id: "preview-desktop", name: "DESKTOP-KM3L4UI", online: true, status: "READY", error: nil, workspaces: [previewWorkspace], sessions: previewWorkspace.sessions), + MobileDeviceDirectoryEntry(id: "preview-mac", name: "Studio Mac", online: true, status: "FAILED", error: "REMOTE_UNAVAILABLE", workspaces: [failedWorkspace], sessions: [failedSession]), + MobileDeviceDirectoryEntry(id: "preview-offline", name: "Office PC", online: false, status: "READY", error: nil, workspaces: [offlineWorkspace], sessions: [cachedSession]) ] workspaceCatalog = [(path: "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/workspace/OpenBitFun", name: "OpenBitFun", selected: true, remoteConnectionId: nil, remoteSshHost: nil)] remoteAssistants = [ diff --git a/src/apps/mobile/ios/OpenBitFun/App/OpenBitFunApp.swift b/src/apps/mobile/ios/OpenBitFun/App/OpenBitFunApp.swift index c06f00e5ec..4c1afe01ff 100644 --- a/src/apps/mobile/ios/OpenBitFun/App/OpenBitFunApp.swift +++ b/src/apps/mobile/ios/OpenBitFun/App/OpenBitFunApp.swift @@ -2,7 +2,9 @@ import SwiftUI @main struct OpenBitFunApp: App { - @State private var showStartupBrand = true + @State private var showStartupBrand = !MobileLaunchConfiguration.streamingRegressionPreview + && MobileLaunchConfiguration.designPreviewScenario() == nil + && StartupRevealPreference.claim() @State private var notificationOnboardingOpen = false @StateObject private var model = MobileLaunchConfiguration.makeModel() @Environment(\.scenePhase) private var scenePhase diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ConversationHeader.swift b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ConversationHeader.swift index 6ca3118845..1fac387b84 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ConversationHeader.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ConversationHeader.swift @@ -90,9 +90,7 @@ struct ConversationHeader: View { } } .frame( - height: resolvedSubtitle == nil - ? MobileDesignGeometry.conversationHeaderCompactHeight - : MobileDesignGeometry.conversationHeaderHeight + height: MobileDesignGeometry.conversationHeaderHeight ) .padding(.horizontal, MobileDesignGeometry.contentGutter) diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Remote/RemoteHomeViews.swift b/src/apps/mobile/ios/OpenBitFun/Features/Remote/RemoteHomeViews.swift index 08e64dfea4..d593b7672c 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Remote/RemoteHomeViews.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Remote/RemoteHomeViews.swift @@ -48,7 +48,7 @@ struct RemoteConnectedHomeView: View { case .connecting, .loadingSessions: homeButton("查看设备", action: browse) case .unavailable: - // The shell's connection banner owns the error detail and retry action. + // The device row exposes connection state and retry without a separate banner. homeButton("查看设备", action: browse) case .ready: EmptyView() @@ -114,51 +114,6 @@ struct RemoteConnectedHomeView: View { } } -/// Matches HarmonyOS ChatStatusBar: page background, two hairlines, one status label. -struct RemoteConversationStatusBar: View { - @ObservedObject var model: MobileAppModel - - private var failed: Bool { model.connectionPhase == .disconnected && !(model.coreErrorMessage ?? "").isEmpty } - private var tone: Color { failed ? OpenBitFunTheme.statusDanger : OpenBitFunTheme.muted } - private var title: String { - model.localized(model.isSending ? "OpenBitFun 正在执行..." : - (model.connectionPhase == .reconnecting ? "正在恢复连接" : (failed ? "连接异常" : "已断开"))) - } - private var detail: String { - model.coreErrorMessage ?? model.localized(model.connectionPhase == .reconnecting ? - "正在重新连接桌面端" : "请重新连接") - } - - var body: some View { - HStack(spacing: 10) { - if model.isSending { Color.clear.frame(width: 68) } - HStack(spacing: 7) { - Circle().fill(tone).frame(width: 8, height: 8) - Text(detail.isEmpty || detail == title ? title : "\(title) · \(detail)") - .font(MobileDesignTypography.bodySmall.font) - .foregroundStyle(model.isSending ? OpenBitFunTheme.muted : tone) - .lineLimit(1) - .truncationMode(.tail) - } - .frame(maxWidth: .infinity, alignment: model.isSending ? .center : .leading) - if model.isSending { - Button(model.localized("停止"), action: model.stopSending) - .font(MobileDesignTypography.bodySmall.font) - .foregroundStyle(OpenBitFunTheme.ink) - .frame(width: 68, height: 34) - .background(OpenBitFunTheme.soft, in: Capsule()) - .buttonStyle(.plain) - } - } - .padding(.horizontal, 24) - .frame(height: 48) - .background(OpenBitFunTheme.page) - .overlay(alignment: .top) { OpenBitFunTheme.line.frame(height: 1) } - .overlay(alignment: .bottom) { OpenBitFunTheme.line.frame(height: 1) } - .accessibilityIdentifier("conversation.connectionStatus") - } -} - /// Signed-out home with a fixed contour mark and looping desktop phrases. struct WelcomeHomeView: View { @ObservedObject var model: MobileAppModel diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Shell/MobileShellView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Shell/MobileShellView.swift index 379b65fbd1..0145d4214c 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Shell/MobileShellView.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Shell/MobileShellView.swift @@ -226,7 +226,7 @@ struct MobileShellView: View { .scaleEffect( x: !sidebarVisible && model.drawerOpen ? 0.985 : 1, y: !sidebarVisible && model.drawerOpen ? 0.992 : 1, - anchor: .leading + anchor: UnitPoint(x: 0, y: MobileDesignGeometry.conversationHeaderHeight / 2 / max(1, viewportHeight)) ) .offset(x: !sidebarVisible && model.drawerOpen ? compactSidebarWidth : 0) .animation( diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Shell/SidebarDeviceRow.swift b/src/apps/mobile/ios/OpenBitFun/Features/Shell/SidebarDeviceRow.swift new file mode 100644 index 0000000000..3037ad0389 --- /dev/null +++ b/src/apps/mobile/ios/OpenBitFun/Features/Shell/SidebarDeviceRow.swift @@ -0,0 +1,47 @@ +import SwiftUI + +/// Device selection and reachability are independent visual states. +struct SidebarDeviceRow: View { + let name: String + let current: Bool + let enabled: Bool + let loading: Bool + let statusColor: Color + let statusLabel: String + let accessibilityID: String + let onSelect: () -> Void + + var body: some View { + Button(action: onSelect) { + HStack(spacing: 8) { + Image(systemName: "desktopcomputer") + .font(.system(size: 18)) + .frame(width: 24, height: 20) + Text(name) + .font(.system(size: 14, weight: current ? .bold : .regular)) + .lineLimit(1) + Spacer(minLength: 0) + if loading { + ProgressView().controlSize(.small) + } else { + Circle().fill(statusColor).frame(width: 8, height: 8) + } + + } + // Explicit styling prevents disabled/default button tint from implying + // that a collapsed or unselected device has disconnected. + .foregroundStyle(OpenBitFunTheme.ink) + .padding(.leading, 8) + .padding(.trailing, 4) + .frame(height: 52) + .background(current ? OpenBitFunTheme.soft : OpenBitFunTheme.transparent, in: RoundedRectangle(cornerRadius: 10)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(!enabled) + .accessibilityAddTraits(current ? .isSelected : []) + .accessibilityIdentifier(accessibilityID) + .accessibilityLabel(Text(name)) + .accessibilityValue(Text(statusLabel)) + } +} diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Shell/SidebarToolsFooter.swift b/src/apps/mobile/ios/OpenBitFun/Features/Shell/SidebarToolsFooter.swift new file mode 100644 index 0000000000..997a13570c --- /dev/null +++ b/src/apps/mobile/ios/OpenBitFun/Features/Shell/SidebarToolsFooter.swift @@ -0,0 +1,50 @@ +import SwiftUI + +/// Shared by compact drawers and wide sidebars. Navigation state stays in the parent. +struct SidebarToolsFooter: View { + let toolsTitle: String + let settingsTitle: String + let toolsEnabled: Bool + let onOpenTools: () -> Void + let onOpenSettings: () -> Void + + var body: some View { + HStack(spacing: 0) { + Button(action: onOpenTools) { + Label(toolsTitle, systemImage: "wrench.and.screwdriver") + .labelStyle(SidebarToolsLabelStyle()) + .padding(.horizontal, 14) + .frame(minWidth: 104, minHeight: 48) + .background(OpenBitFunTheme.card, in: Capsule()) + .overlay(Capsule().stroke(OpenBitFunTheme.line, lineWidth: 0.5)) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .foregroundStyle(OpenBitFunTheme.ink) + .disabled(!toolsEnabled) + .opacity(toolsEnabled ? 1 : 0.45) + .accessibilityIdentifier("sidebar.deviceTools") + Spacer(minLength: 8) + Button(action: onOpenSettings) { + Image(systemName: "gearshape") + .font(.system(size: 20)) + .frame(width: 48, height: 48) + .background(OpenBitFunTheme.card, in: Circle()) + } + .buttonStyle(.plain) + .foregroundStyle(OpenBitFunTheme.ink) + .accessibilityLabel(settingsTitle) + } + .frame(minHeight: 56) + .padding(.leading, 12) + } +} + +private struct SidebarToolsLabelStyle: LabelStyle { + func makeBody(configuration: Configuration) -> some View { + HStack(spacing: 8) { + configuration.icon.font(.system(size: 20)).frame(width: 24, height: 24) + configuration.title.font(.system(size: 15, weight: .medium)).lineLimit(1) + } + } +} diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Shell/SidebarView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Shell/SidebarView.swift index 56325e77e3..2c1e7fbe23 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Shell/SidebarView.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Shell/SidebarView.swift @@ -54,7 +54,7 @@ struct SidebarView: View { private var directoryEntries: [MobileDeviceDirectoryEntry] { model.deviceDirectory } private var selectedDirectoryEntry: MobileDeviceDirectoryEntry? { - directoryEntries.first(where: \.expanded) + directoryEntries.first(where: isCurrentDevice) } private var showsPrimaryNavigation: Bool { @@ -83,7 +83,7 @@ struct SidebarView: View { footer.background(OpenBitFunTheme.page) } .padding(.horizontal, 20) - .padding(.top, 4) + .padding(.top, 0) .padding(.bottom, 16) .frame( width: proxy.size.width, @@ -218,7 +218,7 @@ struct SidebarView: View { .buttonStyle(.plain) .accessibilityLabel(Text(model.localized("搜索"))) } - .frame(height: 50) + .frame(height: MobileDesignGeometry.conversationHeaderHeight) } private var signedOutHeader: some View { @@ -321,26 +321,7 @@ struct SidebarView: View { ForEach(directoryEntries) { device in directoryDeviceSelector(device) - if isCurrentDevice(device), model.connectionPhase != .connected { - HStack { - Text(activeConnectionLabel) - .font(MobileDesignTypography.labelSmall.font) - .foregroundStyle(OpenBitFunTheme.muted) - Spacer(minLength: 0) - Button { - model.retryRemoteConnection() - } label: { - Image(systemName: "arrow.clockwise") - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(OpenBitFunTheme.muted) - .frame(width: 44, height: 44) - } - .buttonStyle(.plain) - .accessibilityLabel(Text(model.localized("重试"))) - .accessibilityIdentifier("sidebar.retryConnection") - } - .padding(.leading, 10) - } + } if let selectedDirectoryEntry { @@ -371,43 +352,20 @@ struct SidebarView: View { } } - @ViewBuilder private func directoryDeviceSelector(_ device: MobileDeviceDirectoryEntry) -> some View { - let expanded = selectedDirectoryEntry?.id == device.id let current = isCurrentDevice(device) - Button { selectDirectoryDevice(device) } label: { - HStack(spacing: 8) { - Image(systemName: "desktopcomputer") - .font(.system(size: 18, weight: .regular)) - .frame(width: 24, height: 20) - Text(device.name) - .font(.system(size: 14, weight: current ? .bold : .regular)) - .foregroundStyle(device.online ? OpenBitFunTheme.ink : OpenBitFunTheme.muted) - .lineLimit(1) - Spacer(minLength: 0) - if device.status == "LOADING" { - ProgressView().controlSize(.small) - } else { - Circle() - .fill(current ? activeConnectionColor : (device.online ? OpenBitFunTheme.statusSuccess : OpenBitFunTheme.muted)) - .frame(width: 8, height: 8) - } - Image(systemName: expanded ? "chevron.down" : "chevron.right") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(device.online ? OpenBitFunTheme.ink : OpenBitFunTheme.muted) - .frame(width: 20, height: 24) - } - .padding(.leading, 8) - .padding(.trailing, 4) - .frame(height: 52) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(!device.online) - .opacity(device.online ? 1 : 0.58) - .accessibilityIdentifier("sidebar.device.\(device.id)") - .accessibilityLabel(Text(device.name)) - .accessibilityValue(Text(current ? activeConnectionLabel : (device.online ? model.localized("在线") : model.localized("离线")))) + return SidebarDeviceRow( + name: device.name, + current: current, + enabled: device.online || (current && model.connectionPhase == .disconnected), + loading: device.status == "LOADING", + statusColor: current ? activeConnectionColor : + (device.online ? OpenBitFunTheme.statusSuccess : OpenBitFunTheme.muted), + statusLabel: current ? activeConnectionLabel : + (device.online ? model.localized("在线") : model.localized("离线")), + accessibilityID: "sidebar.device.\(device.id)", + onSelect: { selectDirectoryDevice(device) } + ) } private func isCurrentDevice(_ device: MobileDeviceDirectoryEntry) -> Bool { @@ -431,15 +389,13 @@ struct SidebarView: View { } private func selectDirectoryDevice(_ device: MobileDeviceDirectoryEntry) { - guard device.online else { return } - if device.expanded { - model.toggleDeviceDirectory(device) + if isCurrentDevice(device), model.connectionPhase == .disconnected { + model.retryRemoteConnection() return } - if let selected = selectedDirectoryEntry, selected.id != device.id { - model.toggleDeviceDirectory(selected) - } - model.toggleDeviceDirectory(device) + guard device.online else { return } + model.loadDeviceDirectory(device) + guard !isCurrentDevice(device) else { return } guard let accountDevice = model.accountDevices.first(where: { $0.id == device.id }) else { return } model.selectRemoteDevice(accountDevice, preserveDrawer: true) } @@ -906,46 +862,15 @@ struct SidebarView: View { } private var authenticatedFooter: some View { - HStack(spacing: 0) { - Button { model.openDeviceTools() } label: { deviceToolsLabel } - .buttonStyle(.plain) - .disabled(!model.remoteConnected) - .accessibilityIdentifier("sidebar.deviceTools") - Spacer(minLength: 0) - Button { model.settingsOpen = true; model.drawerOpen = false } label: { - Image(systemName: "gearshape") - .font(.system(size: 20, weight: .regular)) - .frame(width: 24, height: 24) - .frame(width: 44, height: 44) - .background(OpenBitFunTheme.card) - .clipShape(Circle()) - .shadow(color: OpenBitFunTheme.shadowSubtle, radius: 12, y: 4) - } - .buttonStyle(.plain) - .accessibilityLabel(Text(model.localized("设置"))) - } - .frame(height: 56) - .padding(.leading, 12) + SidebarToolsFooter( + toolsTitle: model.localized("Device tools"), + settingsTitle: model.localized("设置"), + toolsEnabled: model.remoteConnected, + onOpenTools: { model.openDeviceTools() }, + onOpenSettings: { model.settingsOpen = true; model.drawerOpen = false } + ) } - private var deviceToolsLabel: some View { - HStack(spacing: 9) { - Image(systemName: "folder.badge.gearshape") - .font(.system(size: 19, weight: .regular)) - .frame(width: 24, height: 24) - Text(model.localized("Device tools")) - .font(.system(size: 15, weight: .medium)) - .lineLimit(1) - .fixedSize() - } - .foregroundStyle(OpenBitFunTheme.ink) - .frame(minWidth: 98, minHeight: 44) - .background(OpenBitFunTheme.card) - .overlay(Capsule().stroke(OpenBitFunTheme.line, lineWidth: 0.5)) - .clipShape(Capsule()) - .shadow(color: OpenBitFunTheme.shadowSubtle, radius: 12, y: 4) - .opacity(model.remoteConnected && model.remoteCreateInteraction.canSubmit && !model.remoteCreateSubmitting ? 1 : 0.45) - } } private struct RemoteTimeBucket: Identifiable { diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+Account.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+Account.swift index 343092707b..247cae1b1a 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+Account.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+Account.swift @@ -48,6 +48,7 @@ extension MobileAppModel { guard remoteExpectedDeviceKey != targetKey else { return } invalidateTargetScopedFileTransfers() remoteTargetEpoch &+= 1 + accountSelectedDeviceID = device.id remoteExpectedDeviceKey = "account:\(device.id)" remoteInitialSessionReady = false remoteInitialWorkspaceReady = false @@ -248,6 +249,9 @@ extension MobileAppModel { ) } accountDirectoryGeneration = coreAdapter?.syncDeviceDirectory(accountDevices) ?? (accountDirectoryGeneration &+ 1) + if let selectedID = ready.selectedDeviceId { + coreAdapter?.loadDeviceDirectory(selectedID) + } if let link = pendingDeviceLink { pendingDeviceLink = nil submitPairing(url: link) diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift index bc7ba8014b..44f5b4acbf 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift @@ -163,7 +163,6 @@ extension MobileAppModel { id: deviceKey, name: entry.deviceName, online: entry.online, - expanded: entry.expanded, status: entry.status.name, error: entry.error?.name, workspaces: workspaces, @@ -178,9 +177,8 @@ extension MobileAppModel { } } - func toggleDeviceDirectory(_ device: MobileDeviceDirectoryEntry) { - - coreAdapter?.toggleDeviceDirectory(device.id, expanded: !device.expanded) + func loadDeviceDirectory(_ device: MobileDeviceDirectoryEntry) { + coreAdapter?.loadDeviceDirectory(device.id) } func retryDeviceDirectory(_ device: MobileDeviceDirectoryEntry) { diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileCoreAdapter.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileCoreAdapter.swift index 15b83b209c..a55de0c56c 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileCoreAdapter.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileCoreAdapter.swift @@ -197,10 +197,8 @@ final class MobileCoreAdapter { return generation } - func toggleDeviceDirectory(_ deviceID: String, expanded: Bool) { - deviceDirectory.dispatch(intent: expanded - ? DeviceDirectoryIntentExpand(deviceId: deviceID) - : DeviceDirectoryIntentCollapse(deviceId: deviceID)) + func loadDeviceDirectory(_ deviceID: String) { + deviceDirectory.dispatch(intent: DeviceDirectoryIntentLoad(deviceId: deviceID)) } func retryDeviceDirectory(_ deviceID: String) { diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/StartupRevealPreference.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/StartupRevealPreference.swift new file mode 100644 index 0000000000..7004f0ef39 --- /dev/null +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/StartupRevealPreference.swift @@ -0,0 +1,13 @@ +import Foundation + +/// Installation-local presentation state, independent of accounts and app versions. +@MainActor +enum StartupRevealPreference { + static func claim(defaults: UserDefaults = .standard) -> Bool { + let key = "startup.brandRevealShown" + guard !defaults.bool(forKey: key) else { return false } + // Claim before playback so interruptions do not replay the reveal next launch. + defaults.set(true, forKey: key) + return true + } +} diff --git a/src/apps/mobile/ios/OpenBitFun/Presentation/Models/MobilePresentationModels.swift b/src/apps/mobile/ios/OpenBitFun/Presentation/Models/MobilePresentationModels.swift index 0a6e4a891e..0596648649 100644 --- a/src/apps/mobile/ios/OpenBitFun/Presentation/Models/MobilePresentationModels.swift +++ b/src/apps/mobile/ios/OpenBitFun/Presentation/Models/MobilePresentationModels.swift @@ -401,7 +401,6 @@ struct MobileDeviceDirectoryEntry: Identifiable, Equatable { let id: String let name: String let online: Bool - let expanded: Bool let status: String let error: String? let workspaces: [MobileWorkspaceGroup] diff --git a/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings b/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings index 1a2e1a4ab0..2bceb4c267 100644 --- a/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings +++ b/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings @@ -8898,13 +8898,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Device tools" + "value": "Tools" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "设备工具" + "value": "工具" } } } diff --git a/src/apps/mobile/ios/OpenBitFunUITests/RemoteCodeSessionSendUITests.swift b/src/apps/mobile/ios/OpenBitFunUITests/RemoteCodeSessionSendUITests.swift index a250169629..4273d34a46 100644 --- a/src/apps/mobile/ios/OpenBitFunUITests/RemoteCodeSessionSendUITests.swift +++ b/src/apps/mobile/ios/OpenBitFunUITests/RemoteCodeSessionSendUITests.swift @@ -1489,14 +1489,19 @@ final class RemoteHomeLiveUITests: XCTestCase { waitForExpectations(timeout: 30) let drawer = XCTAttachment(screenshot: app.screenshot()); drawer.name = "LiveSelectedDeviceSidebar"; drawer.lifetime = .keepAlways; add(drawer) if ProcessInfo.processInfo.environment["OPENBITFUN_LIVE_HEALTH_OUTAGE"] == "1" { - let retry = app.buttons["sidebar.retryConnection"] - XCTAssertFalse(retry.exists) + let connectedValue = device.value as? String + XCTAssertNotNil(connectedValue) print("LIVE_HEALTH_READY_FOR_HOST_PAUSE") - XCTAssertTrue(retry.waitForExistence(timeout: 90)) + expectation(for: NSPredicate { object, _ in + (object as? XCUIElement)?.value as? String != connectedValue + }, evaluatedWith: device) + waitForExpectations(timeout: 90) XCTAssertTrue(device.exists, "A lost host must not remove the selected device.") let outage = XCTAttachment(screenshot: app.screenshot()) outage.name = "LiveHostUnavailableSidebar"; outage.lifetime = .keepAlways; add(outage) - expectation(for: NSPredicate { object, _ in !(object as? XCUIElement ?? retry).exists }, evaluatedWith: retry) + expectation(for: NSPredicate { object, _ in + (object as? XCUIElement)?.value as? String == connectedValue + }, evaluatedWith: device) waitForExpectations(timeout: 90) XCTAssertTrue(device.exists) let recovered = XCTAttachment(screenshot: app.screenshot()) diff --git a/src/apps/mobile/ios/Testing/StartupRevealPreferenceTests.swift b/src/apps/mobile/ios/Testing/StartupRevealPreferenceTests.swift new file mode 100644 index 0000000000..93419ee373 --- /dev/null +++ b/src/apps/mobile/ios/Testing/StartupRevealPreferenceTests.swift @@ -0,0 +1,17 @@ +import Foundation + +@main +struct StartupRevealPreferenceTests { + @MainActor static func main() { + let suite = "startup-reveal-test-\(UUID().uuidString)" + let first = UserDefaults(suiteName: suite)! + defer { first.removePersistentDomain(forName: suite) } + precondition(StartupRevealPreference.claim(defaults: first)) + precondition(!StartupRevealPreference.claim(defaults: first)) + let reopened = UserDefaults(suiteName: suite)! + precondition(!StartupRevealPreference.claim(defaults: reopened)) + reopened.set("other-account", forKey: "account") + precondition(!StartupRevealPreference.claim(defaults: reopened)) + print("Startup reveal persistence tests passed.") + } +} diff --git a/src/apps/mobile/ios/Testing/run-pure-swift-tests.sh b/src/apps/mobile/ios/Testing/run-pure-swift-tests.sh index 0a5a9774c9..6c76c729b4 100755 --- a/src/apps/mobile/ios/Testing/run-pure-swift-tests.sh +++ b/src/apps/mobile/ios/Testing/run-pure-swift-tests.sh @@ -38,3 +38,9 @@ xcrun --sdk macosx swiftc \ "$OUTPUT_DIR/mobile-process-group-tests" printf '%s\n' 'iOS pure Swift focused tests passed.' + +xcrun --sdk macosx swiftc \ + "$IOS_DIR/OpenBitFun/Infrastructure/StartupRevealPreference.swift" \ + "$SCRIPT_DIR/StartupRevealPreferenceTests.swift" \ + -o "$OUTPUT_DIR/startup-reveal-preference-tests" +"$OUTPUT_DIR/startup-reveal-preference-tests" diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryStore.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryStore.kt index 739ac34a6c..6779214731 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryStore.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryStore.kt @@ -44,8 +44,6 @@ public class DeviceDirectoryStore internal constructor( when (intent) { is DeviceDirectoryIntent.Sync -> sync(intent.devices) is DeviceDirectoryIntent.Load -> load(intent.deviceId) - is DeviceDirectoryIntent.Expand -> expand(intent.deviceId) - is DeviceDirectoryIntent.Collapse -> collapse(intent.deviceId) is DeviceDirectoryIntent.Retry -> retry(intent.deviceId) is DeviceDirectoryIntent.SetWorkspaceExpanded -> setWorkspaceExpanded( intent.deviceId, @@ -144,9 +142,11 @@ public class DeviceDirectoryStore internal constructor( existing.copy( deviceName = device.deviceName, online = device.online, - expanded = if (device.online) existing.expanded else false, - workspaceDirectory = if (device.online) existing.workspaceDirectory else - existing.workspaceDirectory.map { it.copy(expanded = false) }, + workspaceDirectory = existing.workspaceDirectory.map { workspace -> + if (!device.online && workspace.status == WorkspaceDirectoryStatus.LOADING) { + workspace.copy(status = WorkspaceDirectoryStatus.IDLE) + } else workspace + }, status = if (device.online) { existing.status } else if (existing.workspaces.isNotEmpty() || existing.sessions.isNotEmpty()) { @@ -187,41 +187,18 @@ public class DeviceDirectoryStore internal constructor( startLoad(id, slot, entry) } - private fun expand(deviceId: String) { - val id = deviceId.trim() - if (id.isEmpty()) return - val entry = devices[id] ?: return - for ((otherId, other) in devices) { - devices[otherId] = other.copy(expanded = otherId == id) - } - publish() - if (entry.status == DeviceDirectoryStatus.READY) return - if (entry.online) load(id) - } - - private fun collapse(deviceId: String) { - val id = deviceId.trim() - if (id.isEmpty()) return - val entry = devices[id] ?: return - devices[id] = entry.copy(expanded = false) - publish() - } - private fun retry(deviceId: String) { val id = deviceId.trim() if (id.isEmpty()) return val entry = devices[id] ?: return if (!entry.online) return if (loads[id]?.isActive == true) return - val expanded = entry.copy(expanded = true) - devices[id] = expanded - publish() val slot = slotFor(id) if (slot == null) { setEntry(id) { it.copy(status = DeviceDirectoryStatus.FAILED, error = DeviceDirectoryFailure.NOT_SIGNED_IN) } return } - startLoad(id, slot, expanded) + startLoad(id, slot, entry) } private fun setWorkspaceExpanded(deviceId: String, identity: RemoteWorkspaceIdentity, expanded: Boolean) { @@ -349,7 +326,7 @@ public class DeviceDirectoryStore internal constructor( // after a newer generation has already started. val current = devices[id] if (isCurrent(id, generation) && current?.status == DeviceDirectoryStatus.LOADING) { - devices[id] = previous.copy(expanded = current.expanded) + devices[id] = previous.copy(workspaceDirectory = current.workspaceDirectory) publish() } throw cancelled diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryUiState.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryUiState.kt index e4e5a099ed..f3e0500d4e 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryUiState.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryUiState.kt @@ -72,7 +72,6 @@ public data class DeviceDirectoryEntry public constructor( public val deviceId: String, public val deviceName: String, public val online: Boolean, - public val expanded: Boolean, public val status: DeviceDirectoryStatus, public val error: DeviceDirectoryFailure?, public val workspaces: List, @@ -81,18 +80,6 @@ public data class DeviceDirectoryEntry public constructor( public val catalogSource: WorkspaceCatalogSource?, public val recentWorkspaces: List, ) { - public constructor(deviceId: String, deviceName: String, online: Boolean, expanded: Boolean, status: DeviceDirectoryStatus, error: DeviceDirectoryFailure?, workspaces: List, sessions: List, workspaceDirectory: List) : this(deviceId, deviceName, online, expanded, status, error, workspaces, sessions, workspaceDirectory, null, workspaces) - public constructor( - deviceId: String, - deviceName: String, - online: Boolean, - expanded: Boolean, - status: DeviceDirectoryStatus, - error: DeviceDirectoryFailure?, - workspaces: List, - sessions: List, - ) : this(deviceId, deviceName, online, expanded, status, error, workspaces, sessions, emptyList()) - public fun sessionsForWorkspace(path: String, remoteConnectionId: String?, remoteSshHost: String?): List { val identity = RemoteWorkspaceIdentity(path, remoteConnectionId, remoteSshHost) return sessions.filter { it.belongsTo(identity, workspaces.map { row -> row.identity() }) } @@ -106,23 +93,18 @@ public data class DeviceDirectoryEntry public constructor( } public companion object { - private fun normalizeWorkspacePath(path: String): String { - val trimmed = path.trim() - val normalized = trimmed.trimEnd('/') - return normalized.ifEmpty { trimmed } - } - public fun empty(deviceId: String, deviceName: String, online: Boolean): DeviceDirectoryEntry = DeviceDirectoryEntry( deviceId = deviceId, deviceName = deviceName, online = online, - expanded = false, status = DeviceDirectoryStatus.IDLE, error = null, workspaces = emptyList(), sessions = emptyList(), workspaceDirectory = emptyList(), + catalogSource = null, + recentWorkspaces = emptyList(), ) } } @@ -158,14 +140,6 @@ public sealed interface DeviceDirectoryIntent { public val deviceId: String, ) : DeviceDirectoryIntent - public data class Expand public constructor( - public val deviceId: String, - ) : DeviceDirectoryIntent - - public data class Collapse public constructor( - public val deviceId: String, - ) : DeviceDirectoryIntent - public data class Retry public constructor( public val deviceId: String, ) : DeviceDirectoryIntent diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryStoreTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryStoreTest.kt index a33aa4c0b9..1f386eacff 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryStoreTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryStoreTest.kt @@ -33,6 +33,26 @@ import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class DeviceDirectoryStoreTest { + @Test + fun workspaceDisclosureSurvivesRefreshAndOfflineRoundTrip() = runTest { + val store = DeviceDirectoryStore.create(this, FakeDeviceStoreFactory(mutableMapOf("a" to FakeDeviceTransport("a")))) + store.dispatch(DeviceDirectoryIntent.Sync(listOf(DeviceDirectoryDevice("a", true)))) + store.dispatch(DeviceDirectoryIntent.Load("a")) + advanceUntilIdle() + store.dispatch(DeviceDirectoryIntent.SetWorkspaceExpanded("a", "/repo-a", true)) + advanceUntilIdle() + for (online in listOf(true, false, true)) { + store.dispatch(DeviceDirectoryIntent.Sync(listOf(DeviceDirectoryDevice("a", online)))) + val entry = store.state.value.device("a")!! + assertTrue(entry.workspaceDirectory.single().expanded) + } + store.dispatch(DeviceDirectoryIntent.SetWorkspaceExpanded("a", "/repo-a", false)) + store.dispatch(DeviceDirectoryIntent.Load("a")) + advanceUntilIdle() + assertFalse(store.state.value.device("a")!!.workspaceDirectory.single().expanded) + store.stop() + } + @Test fun devicesLoadIndependentlyAndOneFailureDoesNotClearOthers() = runTest { val transports = mutableMapOf( @@ -83,7 +103,7 @@ class DeviceDirectoryStoreTest { val cache = MemoryDirectorySessions() val store = DeviceDirectoryStore.create(this, CachedDeviceStoreFactory(transport, cache, history)) store.dispatch(DeviceDirectoryIntent.Sync(listOf(DeviceDirectoryDevice("a", true)))) - store.dispatch(DeviceDirectoryIntent.Expand("a")) + store.dispatch(DeviceDirectoryIntent.Load("a")) advanceUntilIdle() store.dispatch(DeviceDirectoryIntent.SetWorkspaceExpanded("a", "/repo-a", true)) advanceUntilIdle() @@ -99,7 +119,7 @@ class DeviceDirectoryStoreTest { assertEquals(listOf("s-a"), ready.sessions.map { it.id }) assertTrue(history.byDevice.getValue("a").any { it.path == "/repo-a" }) store.dispatch(DeviceDirectoryIntent.Sync(listOf(DeviceDirectoryDevice("a", false)))) - store.dispatch(DeviceDirectoryIntent.Expand("a")) + store.dispatch(DeviceDirectoryIntent.Load("a")) assertTrue(store.state.value.device("a")!!.workspaces.isEmpty()) assertEquals(listOf("s-a"), store.state.value.device("a")!!.sessions.map { it.id }) store.stop() @@ -114,7 +134,7 @@ class DeviceDirectoryStoreTest { val sessions = MemoryDirectorySessions() val store = DeviceDirectoryStore.create(this, CachedDeviceStoreFactory(transport, sessions, MemoryDirectoryWorkspaces())) store.dispatch(DeviceDirectoryIntent.Sync(listOf(DeviceDirectoryDevice("a", true)))) - store.dispatch(DeviceDirectoryIntent.Expand("a")); advanceUntilIdle() + store.dispatch(DeviceDirectoryIntent.Load("a")); advanceUntilIdle() val gateA = CompletableDeferred(); val gateB = CompletableDeferred() transport.sessionGateByConnection["ssh-a"] = gateA transport.sessionGateByConnection["ssh-b"] = gateB @@ -157,30 +177,25 @@ class DeviceDirectoryStoreTest { } @Test - fun expandPreservesCachedDataWithoutRefetching() = runTest { + fun loadingReadyDirectoryPreservesDataWithoutRefetching() = runTest { val transport = FakeDeviceTransport("a") val store = DeviceDirectoryStore.create(this, FakeDeviceStoreFactory(mutableMapOf("a" to transport))) store.dispatch(DeviceDirectoryIntent.Sync(listOf(DeviceDirectoryDevice("a", true)))) - store.dispatch(DeviceDirectoryIntent.Expand("a")) + store.dispatch(DeviceDirectoryIntent.Load("a")) advanceUntilIdle() val first = store.state.value.device("a")!! - assertTrue(first.expanded) assertEquals(DeviceDirectoryStatus.READY, first.status) assertTrue(first.sessions.isEmpty()) val listSessionsBefore = transport.commands.count { it.cmd == "list_sessions" } assertEquals(0, listSessionsBefore) - store.dispatch(DeviceDirectoryIntent.Collapse("a")) - assertFalse(store.state.value.device("a")!!.expanded) - - // Re-expanding a ready device keeps the cache instead of re-fetching. - store.dispatch(DeviceDirectoryIntent.Expand("a")) + // Revisiting a ready device keeps its data instead of re-fetching. + store.dispatch(DeviceDirectoryIntent.Load("a")) advanceUntilIdle() val again = store.state.value.device("a")!! - assertTrue(again.expanded) assertEquals(DeviceDirectoryStatus.READY, again.status) assertTrue(again.sessions.isEmpty()) assertEquals(listSessionsBefore, transport.commands.count { it.cmd == "list_sessions" })