diff --git a/src/apps/mobile/android/app/build.gradle.kts b/src/apps/mobile/android/app/build.gradle.kts
index fd5dec0842..fdc0014bc0 100644
--- a/src/apps/mobile/android/app/build.gradle.kts
+++ b/src/apps/mobile/android/app/build.gradle.kts
@@ -76,7 +76,11 @@ dependencies {
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
- implementation(libs.google.code.scanner)
+ implementation("androidx.camera:camera-camera2:1.4.2")
+ implementation("androidx.camera:camera-lifecycle:1.4.2")
+ implementation("androidx.camera:camera-view:1.4.2")
+ // Bundle QR recognition so pairing also works without Play Services downloads.
+ implementation("com.google.mlkit:barcode-scanning:17.3.0")
implementation(libs.androidx.window)
testImplementation("junit:junit:4.13.2")
diff --git a/src/apps/mobile/android/app/src/androidTest/assets/pairing-qr.png b/src/apps/mobile/android/app/src/androidTest/assets/pairing-qr.png
new file mode 100644
index 0000000000..2e7307b542
Binary files /dev/null and b/src/apps/mobile/android/app/src/androidTest/assets/pairing-qr.png differ
diff --git a/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/ConnectionSheetChromeTest.kt b/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/ConnectionSheetChromeTest.kt
new file mode 100644
index 0000000000..50ffb5590b
--- /dev/null
+++ b/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/ConnectionSheetChromeTest.kt
@@ -0,0 +1,84 @@
+package com.openbitfun.mobile.app
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.test.*
+import androidx.compose.ui.test.junit4.v2.createComposeRule
+import androidx.compose.ui.unit.dp
+import com.openbitfun.mobile.app.ui.common.connectionSheetTextStyle
+import com.openbitfun.mobile.app.ui.common.ConnectionSheetHeader
+import com.openbitfun.mobile.app.ui.common.ConnectionSheetFooter
+import com.openbitfun.mobile.app.ui.theme.OpenBitFunTheme
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+
+class ConnectionSheetChromeTest {
+ @get:Rule val composeRule = createComposeRule()
+
+ @Test fun closeAndManualActionsKeepHarmonyGeometryAndRemainClickable() {
+ var closed = false
+ var manual = false
+ composeRule.setContent {
+ OpenBitFunTheme(dark = false) {
+ Column(Modifier.requiredSize(360.dp, 700.dp)) {
+ ConnectionSheetHeader { closed = true }
+ Spacer(Modifier.weight(1f))
+ ConnectionSheetFooter("Pair manually", onClick = { manual = true })
+ }
+ }
+ }
+ composeRule.onNodeWithContentDescription(
+ androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().targetContext.getString(R.string.common_close))
+ .assertWidthIsEqualTo(48.dp).assertHeightIsEqualTo(48.dp).performClick()
+ composeRule.onNodeWithText("Pair manually").assertHeightIsEqualTo(48.dp)
+ .assertWidthIsEqualTo(320.dp).performClick()
+ composeRule.runOnIdle { assertTrue(closed); assertTrue(manual) }
+ }
+ @Test fun chineseSheetTextUsesTheReferenceLineBoxes() {
+ composeRule.setContent {
+ OpenBitFunTheme(dark = false) {
+ Column {
+ androidx.compose.material3.Text("扫描桌面端二维码",
+ style = androidx.compose.material3.MaterialTheme.typography.displayMedium.connectionSheetTextStyle())
+ androidx.compose.material3.Text("第一行说明\n第二行说明",
+ style = androidx.compose.material3.MaterialTheme.typography.bodyMedium.connectionSheetTextStyle())
+ }
+ }
+ }
+ composeRule.onNodeWithText("扫描桌面端二维码").assertIsDisplayed().assertHeightIsEqualTo(28.dp)
+ val body = composeRule.onNodeWithText("第一行说明\n第二行说明").assertIsDisplayed()
+ // Android rounds the first/last font baselines independently; preserve
+ // the two 21dp lines within one layout unit without padding the glyphs.
+ val bounds = body.getUnclippedBoundsInRoot()
+ assertTrue(kotlin.math.abs((bounds.bottom - bounds.top).value - 42f) <= 1f)
+ }
+
+ @Test fun compactLoginKeepsContentGeometryAtPhoneAndWideWidths() {
+ var width by androidx.compose.runtime.mutableStateOf(386.dp)
+ composeRule.setContent {
+ OpenBitFunTheme(dark = false) {
+ com.openbitfun.mobile.app.ui.account.AccountLoginPage(
+ state = com.openbitfun.mobile.core.feature.account.AccountUiState.SignedOut,
+ onBack = {}, onLogin = {},
+ modifier = Modifier.requiredWidth(width).testTag("login-panel"))
+ }
+ }
+ val context = androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().targetContext
+ for (panelWidth in listOf(386.dp, 560.dp)) {
+ composeRule.runOnIdle { width = panelWidth }
+ val panel = composeRule.onNodeWithTag("login-panel").assertHeightIsEqualTo(280.dp)
+ .assertWidthIsEqualTo(panelWidth).getUnclippedBoundsInRoot()
+ val labels = composeRule.onAllNodesWithText(context.getString(R.string.account_login_title))
+ val title = labels[0].getUnclippedBoundsInRoot()
+ val action = labels[1].getUnclippedBoundsInRoot()
+ assertTrue(kotlin.math.abs((title.top - panel.top).value - 56f) < 1f)
+ assertTrue(kotlin.math.abs((action.top - panel.top).value - 208f) < 1f)
+ labels[1].assertHeightIsEqualTo(48.dp).assertWidthIsEqualTo(panelWidth - 40.dp)
+ }
+ }
+
+}
diff --git a/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/InlineQrDecoderTest.kt b/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/InlineQrDecoderTest.kt
new file mode 100644
index 0000000000..39d39f2d27
--- /dev/null
+++ b/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/InlineQrDecoderTest.kt
@@ -0,0 +1,34 @@
+package com.openbitfun.mobile.app
+
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.Color
+import androidx.test.platform.app.InstrumentationRegistry
+import com.google.android.gms.tasks.Tasks
+import com.google.mlkit.vision.barcode.BarcodeScanning
+import com.google.mlkit.vision.barcode.BarcodeScannerOptions
+import com.google.mlkit.vision.barcode.common.Barcode
+import com.google.mlkit.vision.common.InputImage
+import org.junit.Assert.*
+import org.junit.Test
+import java.util.concurrent.TimeUnit
+
+class InlineQrDecoderTest {
+ @Test fun bundledDecoderReadsQrAndIgnoresBlankFrames() {
+ val scanner = BarcodeScanning.getClient(BarcodeScannerOptions.Builder()
+ .setBarcodeFormats(Barcode.FORMAT_QR_CODE).build())
+ val bitmap = InstrumentationRegistry.getInstrumentation().context.assets.open("pairing-qr.png")
+ .use { BitmapFactory.decodeStream(it) }
+ val blank = Bitmap.createBitmap(320, 320, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.WHITE) }
+ try {
+ val codes = Tasks.await(scanner.process(InputImage.fromBitmap(bitmap, 0)), 20, TimeUnit.SECONDS)
+ assertEquals(listOf("openbitfun-qr-decoder-fixture"), codes.map { it.rawValue })
+ val empty = Tasks.await(scanner.process(InputImage.fromBitmap(blank, 0)), 20, TimeUnit.SECONDS)
+ assertTrue(empty.isEmpty())
+ } finally {
+ scanner.close()
+ bitmap.recycle()
+ blank.recycle()
+ }
+ }
+}
diff --git a/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/MobilePreviewStatesVisualTest.kt b/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/MobilePreviewStatesVisualTest.kt
index 0c8263b411..ab6e79f760 100644
--- a/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/MobilePreviewStatesVisualTest.kt
+++ b/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/MobilePreviewStatesVisualTest.kt
@@ -82,6 +82,7 @@ class MobilePreviewStatesVisualTest {
fun circleStatesHaveStableTouchTargetsAndMoreActionIsSafe() {
composeRule.setContent { MobilePreviewCircleStates() }
composeRule.onNodeWithTag(MOBILE_PREVIEW_CIRCLE_STATES_TEST_TAG).assertIsDisplayed()
+ // The shared control_touch_size contract is 48dp on all three platforms.
listOf(
MOBILE_PREVIEW_CIRCLE_SIDEBAR_TEST_TAG,
MOBILE_PREVIEW_CIRCLE_MORE_TEST_TAG,
@@ -89,8 +90,8 @@ class MobilePreviewStatesVisualTest {
).forEach { tag ->
composeRule.onNodeWithTag(tag)
.assertIsDisplayed()
- .assertWidthIsEqualTo(44.dp)
- .assertHeightIsEqualTo(44.dp)
+ .assertWidthIsEqualTo(48.dp)
+ .assertHeightIsEqualTo(48.dp)
}
composeRule.onNodeWithContentDescription("More actions").performClick()
composeRule.onNodeWithTag(MOBILE_PREVIEW_CIRCLE_MORE_TEST_TAG).assertIsDisplayed()
diff --git a/src/apps/mobile/android/app/src/main/AndroidManifest.xml b/src/apps/mobile/android/app/src/main/AndroidManifest.xml
index 0b7609a584..6e6f48f512 100644
--- a/src/apps/mobile/android/app/src/main/AndroidManifest.xml
+++ b/src/apps/mobile/android/app/src/main/AndroidManifest.xml
@@ -6,6 +6,9 @@
+
+
+
-
Unit,
+ onReady: () -> Unit,
+ onPermissionDenied: () -> Unit,
+ onUnavailable: () -> Unit,
+) {
+ val context = LocalContext.current
+ val owner = LocalLifecycleOwner.current
+ val ready by rememberUpdatedState(onReady)
+ val detected by rememberUpdatedState(onCode)
+ val denied by rememberUpdatedState(onPermissionDenied)
+ val unavailable by rememberUpdatedState(onUnavailable)
+ var granted by remember { mutableStateOf(
+ ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
+ ) }
+ DisposableEffect(owner) {
+ val observer = androidx.lifecycle.LifecycleEventObserver { _, event ->
+ if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) {
+ granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
+ }
+ }
+ owner.lifecycle.addObserver(observer)
+ onDispose { owner.lifecycle.removeObserver(observer) }
+ }
+ val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
+ granted = it
+ if (!it) denied()
+ }
+ LaunchedEffect(Unit) { if (!granted) permission.launch(Manifest.permission.CAMERA) }
+ val view = remember(context) { PreviewView(context).apply {
+ implementationMode = PreviewView.ImplementationMode.COMPATIBLE
+ scaleType = PreviewView.ScaleType.FILL_CENTER
+ } }
+ AndroidView(factory = { view }, modifier = modifier)
+ if (granted && !paused) {
+ DisposableEffect(view, owner) {
+ val executor = ContextCompat.getMainExecutor(context)
+ val future = ProcessCameraProvider.getInstance(context)
+ val scanner = BarcodeScanning.getClient(BarcodeScannerOptions.Builder()
+ .setBarcodeFormats(Barcode.FORMAT_QR_CODE).build())
+ val preview = Preview.Builder().build().also { it.setSurfaceProvider(view.surfaceProvider) }
+ val analysis = ImageAnalysis.Builder()
+ .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST).build()
+ var disposed = false
+ var delivered = false
+ var failed = false
+ var provider: ProcessCameraProvider? = null
+ analysis.setAnalyzer(executor) { proxy ->
+ val image = proxy.image
+ if (disposed || delivered || failed || image == null) {
+ proxy.close()
+ } else {
+ scanner.process(InputImage.fromMediaImage(image, proxy.imageInfo.rotationDegrees))
+ .addOnSuccessListener(executor) { codes ->
+ val value = codes.firstNotNullOfOrNull { it.rawValue?.takeIf(String::isNotBlank) }
+ if (!disposed && !delivered && value != null) {
+ delivered = true
+ detected(value)
+ }
+ }
+ .addOnFailureListener(executor) {
+ if (!disposed && !failed) { failed = true; unavailable() }
+ }
+ .addOnCompleteListener { proxy.close() }
+ }
+ }
+ future.addListener({
+ if (!disposed) {
+ try {
+ provider = future.get()
+ provider!!.bindToLifecycle(owner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
+ ready()
+ } catch (_: Exception) {
+ if (!failed) { failed = true; unavailable() }
+ }
+ }
+ }, executor)
+ onDispose {
+ disposed = true
+ analysis.clearAnalyzer()
+ provider?.unbind(preview, analysis)
+ scanner.close()
+ }
+ }
+ }
+}
diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/state/AppShellState.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/state/AppShellState.kt
index 9fb2a39234..13fcef2fae 100644
--- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/state/AppShellState.kt
+++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/state/AppShellState.kt
@@ -54,6 +54,7 @@ internal class AppShellState(
remoteSessionId: String? = null,
remoteCreating: Boolean = false,
remoteScanRequested: Boolean = false,
+ remoteConnectOpen: Boolean = false,
) {
internal var surface: MobileSurface by mutableStateOf(surface)
private set
@@ -85,17 +86,27 @@ internal class AppShellState(
internal var remoteScanRequested: Boolean by mutableStateOf(remoteScanRequested)
private set
+ internal var remoteConnectOpen: Boolean by mutableStateOf(remoteConnectOpen)
+ private set
+
+ internal fun closeRemoteConnect() {
+ remoteConnectOpen = false
+ remoteScanRequested = false
+ }
+
internal fun show(next: MobileSurface) {
surface = next
}
internal fun openRemoteSession(sessionId: String) {
+ closeRemoteConnect()
surface = MobileSurface.REMOTE
remoteCreating = false
remoteSessionId = sessionId
}
internal fun createRemoteSession() {
+ closeRemoteConnect()
surface = MobileSurface.REMOTE
remoteScanRequested = false
remoteCreating = true
@@ -103,6 +114,7 @@ internal class AppShellState(
}
internal fun closeRemoteSession() {
+ closeRemoteConnect()
remoteCreating = false
remoteSessionId = null
}
@@ -115,6 +127,7 @@ internal class AppShellState(
* [openRemoteScanner] remains for entry points whose whole job is to scan.
*/
internal fun openRemoteConnect() {
+ remoteConnectOpen = true
surface = MobileSurface.REMOTE
remoteCreating = false
remoteSessionId = null
@@ -122,6 +135,7 @@ internal class AppShellState(
}
internal fun openRemoteScanner() {
+ remoteConnectOpen = true
surface = MobileSurface.REMOTE
remoteCreating = false
remoteSessionId = null
@@ -189,6 +203,7 @@ internal class AppShellState(
it.remoteSessionId,
it.remoteCreating,
it.remoteScanRequested,
+ it.remoteConnectOpen,
)
},
restore = {
@@ -203,6 +218,8 @@ internal class AppShellState(
remoteSessionId = it.getOrNull(7) as String?,
remoteCreating = it.getOrNull(8) as? Boolean ?: false,
remoteScanRequested = it.getOrNull(9) as? Boolean ?: false,
+ remoteConnectOpen = it.getOrNull(10) as? Boolean
+ ?: (it.getOrNull(9) as? Boolean ?: false),
)
},
)
diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/account/AccountScreen.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/account/AccountScreen.kt
index 39f6df2c66..91a8f35976 100644
--- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/account/AccountScreen.kt
+++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/account/AccountScreen.kt
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
@@ -51,12 +52,16 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.ui.graphics.asImageBitmap
+import com.openbitfun.mobile.app.ui.common.ConnectionSheetHeader
+import com.openbitfun.mobile.app.ui.common.ConnectionSheetFooter
+import com.openbitfun.mobile.app.ui.common.connectionSheetTextStyle
import com.openbitfun.mobile.app.R
import com.openbitfun.mobile.app.platform.deviceIdentity
import com.openbitfun.mobile.app.viewmodel.AccountViewModel
import com.openbitfun.mobile.core.feature.account.AccountFailureReason
import com.openbitfun.mobile.core.feature.account.AccountIntent
import com.openbitfun.mobile.core.feature.account.AccountUiState
+import com.openbitfun.mobile.app.ui.theme.generated.MobileDesignGeometry
import com.openbitfun.mobile.app.ui.theme.openBitFunColors
private val AccountCardShape = RoundedCornerShape(24.dp)
@@ -91,7 +96,7 @@ internal fun AccountScreen(
}
@Composable
-private fun AccountLoginPage(
+internal fun AccountLoginPage(
state: AccountUiState,
onBack: () -> Unit,
onLogin: () -> Unit,
@@ -100,36 +105,35 @@ private fun AccountLoginPage(
val busy = state is AccountUiState.SigningIn || state is AccountUiState.Authorizing
val canSubmit = !busy
val uriHandler = androidx.compose.ui.platform.LocalUriHandler.current
- Box(modifier.fillMaxSize()) {
- Column(
- modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState())
- .padding(start = 28.dp, end = 28.dp, top = 118.dp, bottom = 44.dp),
- ) {
- Text(stringResource(R.string.account_login_title), fontSize = 32.sp, lineHeight = 38.sp, fontWeight = FontWeight.Bold)
- Text(stringResource(R.string.account_login_body), fontSize = 15.sp, lineHeight = 22.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 12.dp, bottom = 42.dp))
- (state as? AccountUiState.Authorizing)?.let { authorization ->
- Button(onClick = { uriHandler.openUri(authorization.authorizationUrl) }, modifier = Modifier.fillMaxWidth()) {
- Text(stringResource(R.string.account_open_github))
+ Column(modifier.fillMaxWidth()) {
+ ConnectionSheetHeader(onBack, uniformGlyph = true)
+ Box(Modifier.weight(1f, fill = false).fillMaxWidth(), contentAlignment = Alignment.TopCenter) {
+ Column(
+ modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState())
+ .padding(horizontal = 20.dp).heightIn(min = MobileDesignGeometry.LoginSheetBodyMinHeight),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(stringResource(R.string.account_login_title),
+ style = MaterialTheme.typography.displayMedium.connectionSheetTextStyle(), textAlign = TextAlign.Center)
+ Text(stringResource(R.string.account_login_body),
+ style = MaterialTheme.typography.bodyMedium.connectionSheetTextStyle(),
+ color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center,
+ modifier = Modifier.padding(top = 8.dp))
+ (state as? AccountUiState.Authorizing)?.let { authorization ->
+ Button(onClick = { uriHandler.openUri(authorization.authorizationUrl) }) {
+ Text(stringResource(R.string.account_open_github))
+ }
+ }
+ (state as? AccountUiState.Failed)?.let { failure ->
+ Text(stringResource(failure.reason.messageRes()), color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall.connectionSheetTextStyle(), modifier = Modifier.padding(top = 12.dp))
}
}
- (state as? AccountUiState.Failed)?.let { failure ->
- Text(stringResource(failure.reason.messageRes()), color = MaterialTheme.colorScheme.error, fontSize = 13.sp, lineHeight = 19.sp, modifier = Modifier.padding(top = 12.dp))
- }
- Spacer(Modifier.height(if (state is AccountUiState.Failed) 22.dp else 30.dp))
- Button(
- onClick = onLogin,
- enabled = canSubmit,
- shape = RoundedCornerShape(18.dp),
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.primary,
- contentColor = MaterialTheme.colorScheme.onPrimary,
- disabledContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.28f),
- disabledContentColor = MaterialTheme.colorScheme.onPrimary,
- ),
- modifier = Modifier.fillMaxWidth().height(56.dp),
- ) { Text(stringResource(if (busy) R.string.account_signing_in else R.string.account_sign_in), fontSize = 17.sp, fontWeight = FontWeight.Bold) }
}
- AccountBackButton(onBack, Modifier.padding(start = 28.dp, top = 22.dp))
+ ConnectionSheetFooter(
+ label = stringResource(if (busy) R.string.account_signing_in else R.string.account_login_title),
+ primary = true, elevated = false, enabled = canSubmit, onClick = onLogin,
+ )
}
}
diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/common/AdaptiveModalSurface.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/common/AdaptiveModalSurface.kt
index df2779a57e..cf4383c383 100644
--- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/common/AdaptiveModalSurface.kt
+++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/common/AdaptiveModalSurface.kt
@@ -2,11 +2,20 @@ package com.openbitfun.mobile.app.ui.common
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.statusBars
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.material3.BottomSheetDefaults
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.width
@@ -16,6 +25,9 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.window.DialogWindowProvider
+import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -41,10 +53,32 @@ internal fun AdaptiveModalSurface(
visible: Boolean,
placement: SettingsPlacement,
onDismissRequest: () -> Unit,
+ edgeToEdgeContent: Boolean = false,
+ fitContent: Boolean = false,
content: @Composable (Modifier) -> Unit,
) {
if (!visible) return
+ if (fitContent) {
+ Dialog(onDismissRequest = onDismissRequest,
+ properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false)) {
+ val window = (LocalView.current.parent as? DialogWindowProvider)?.window
+ SideEffect { window?.setDimAmount(0f) }
+ Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.scrim)
+ .clickable(interactionSource = null, indication = null, onClick = onDismissRequest)
+ .safeDrawingPadding().imePadding().padding(MobileDesignGeometry.LoginSheetOuterMargin),
+ contentAlignment = Alignment.BottomCenter) {
+ Surface(modifier = Modifier.widthIn(max = MobileDesignGeometry.LoginSheetMaxWidth).fillMaxWidth()
+ .clickable(interactionSource = null, indication = null, onClick = {}),
+ shape = RoundedCornerShape(MobileDesignGeometry.SheetTopRadius),
+ color = MaterialTheme.colorScheme.background) {
+ content(Modifier.fillMaxWidth())
+ }
+ }
+ }
+ return
+ }
+
if (placement.mode == SettingsPlacementMode.SIDE) {
Dialog(
onDismissRequest = onDismissRequest,
@@ -80,6 +114,8 @@ internal fun AdaptiveModalSurface(
return
}
+ val height = with(LocalDensity.current) { LocalWindowInfo.current.containerSize.height.toDp() }
+ val topInset = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
ModalBottomSheet(
onDismissRequest = onDismissRequest,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
@@ -89,8 +125,13 @@ internal fun AdaptiveModalSurface(
topEnd = MobileDesignGeometry.SheetTopRadius,
),
dragHandle = null,
+ contentWindowInsets = { if (edgeToEdgeContent) WindowInsets(0) else BottomSheetDefaults.windowInsets },
) {
- val modifier = if (placement.mode == SettingsPlacementMode.FOLD_OPERATE && placement.height > 0) {
+ val modifier = if (fitContent) {
+ Modifier.fillMaxWidth().heightIn(max = (height - topInset - 8.dp).coerceAtLeast(0.dp))
+ } else if (edgeToEdgeContent && placement.mode != SettingsPlacementMode.FOLD_OPERATE) {
+ Modifier.fillMaxWidth().height((height - topInset - 8.dp).coerceAtLeast(0.dp))
+ } else if (placement.mode == SettingsPlacementMode.FOLD_OPERATE && placement.height > 0) {
Modifier.fillMaxWidth().height(placement.height.dp)
} else {
Modifier.fillMaxWidth().fillMaxHeight(0.94f)
diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/common/ConnectionSheetChrome.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/common/ConnectionSheetChrome.kt
new file mode 100644
index 0000000000..3369e22fb0
--- /dev/null
+++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/common/ConnectionSheetChrome.kt
@@ -0,0 +1,68 @@
+package com.openbitfun.mobile.app.ui.common
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.openbitfun.mobile.app.R
+import com.openbitfun.mobile.app.ui.theme.generated.MobileDesignGeometry
+
+@Composable
+internal fun ConnectionSheetHeader(onClose: () -> Unit) = ConnectionSheetHeader(onClose, false)
+
+@Composable
+internal fun ConnectionSheetHeader(onClose: () -> Unit, uniformGlyph: Boolean) {
+ Box(Modifier.fillMaxWidth().height(MobileDesignGeometry.SheetHeaderHeight).padding(end = 8.dp)) {
+ IconButton(onClick = onClose, modifier = Modifier.align(Alignment.CenterEnd)
+ .size(MobileDesignGeometry.ControlTouchSize)) {
+ if (uniformGlyph) {
+ val ink = MaterialTheme.colorScheme.onSurface
+ val closeLabel = stringResource(R.string.common_close)
+ androidx.compose.foundation.Canvas(Modifier.size(18.dp).semantics { contentDescription = closeLabel }) {
+ drawLine(ink, androidx.compose.ui.geometry.Offset(2.dp.toPx(), 2.dp.toPx()),
+ androidx.compose.ui.geometry.Offset(16.dp.toPx(), 16.dp.toPx()), 1.5.dp.toPx(), androidx.compose.ui.graphics.StrokeCap.Round)
+ drawLine(ink, androidx.compose.ui.geometry.Offset(16.dp.toPx(), 2.dp.toPx()),
+ androidx.compose.ui.geometry.Offset(2.dp.toPx(), 16.dp.toPx()), 1.5.dp.toPx(), androidx.compose.ui.graphics.StrokeCap.Round)
+ }
+ } else Icon(painterResource(R.drawable.ic_symbol_xmark), stringResource(R.string.common_close),
+ modifier = Modifier.size(18.dp))
+ }
+ }
+}
+
+@Composable
+internal fun ConnectionSheetFooter(label: String, primary: Boolean = false, enabled: Boolean = true,
+ modifier: Modifier = Modifier, elevated: Boolean = true, onClick: () -> Unit) {
+ Box(modifier.fillMaxWidth().padding(start = MobileDesignGeometry.SheetHorizontalPadding,
+ end = MobileDesignGeometry.SheetHorizontalPadding, top = 10.dp, bottom = 24.dp),
+ contentAlignment = Alignment.Center) {
+ Button(onClick = onClick, enabled = enabled,
+ modifier = Modifier.widthIn(max = 520.dp).fillMaxWidth().height(MobileDesignGeometry.SheetActionHeight),
+ shape = RoundedCornerShape(24.dp),
+ border = if (primary) null else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = if (primary) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface,
+ contentColor = if (primary) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface),
+ elevation = ButtonDefaults.buttonElevation(defaultElevation = if (elevated) 3.dp else 0.dp)) {
+ Text(label, style = MaterialTheme.typography.labelLarge.connectionSheetTextStyle())
+ }
+ }
+}
+
+// These sheet roles reserve their full contract line box, including for CJK fallback fonts.
+internal fun androidx.compose.ui.text.TextStyle.connectionSheetTextStyle() = copy(
+ platformStyle = androidx.compose.ui.text.PlatformTextStyle(includeFontPadding = false),
+ lineHeightStyle = androidx.compose.ui.text.style.LineHeightStyle(
+ alignment = androidx.compose.ui.text.style.LineHeightStyle.Alignment.Center,
+ trim = androidx.compose.ui.text.style.LineHeightStyle.Trim.Both,
+ mode = androidx.compose.ui.text.style.LineHeightStyle.Mode.Tight,
+ ),
+)
diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/common/SignedOutConnectionActions.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/common/SignedOutConnectionActions.kt
index 7b92814670..6ccf13fa05 100644
--- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/common/SignedOutConnectionActions.kt
+++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/common/SignedOutConnectionActions.kt
@@ -32,6 +32,7 @@ internal fun SignedOutConnectionActions(
onOpenAccount: () -> Unit,
modifier: Modifier = Modifier,
showScan: Boolean = true,
+ primaryScan: Boolean = false,
enabled: Boolean = true,
buttonHeight: Dp = 48.dp,
spacing: Dp = 10.dp,
@@ -48,7 +49,8 @@ internal fun SignedOutConnectionActions(
.fillMaxWidth()
.height(buttonHeight)
.clip(shape)
- .border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape)
+ .background(if (primaryScan) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface)
+ .border(1.dp, if (primaryScan) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, shape)
.clickable(enabled = enabled, role = Role.Button, onClick = onScan)
.semantics(mergeDescendants = true) {
contentDescription = scanLabel
@@ -59,7 +61,7 @@ internal fun SignedOutConnectionActions(
scanLabel,
fontSize = fontSize.sp,
fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.onSurface,
+ color = if (primaryScan) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface,
)
}
}
@@ -68,7 +70,8 @@ internal fun SignedOutConnectionActions(
.fillMaxWidth()
.height(buttonHeight)
.clip(shape)
- .background(MaterialTheme.colorScheme.primary)
+ .background(if (primaryScan) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.primary)
+ .border(1.dp, if (primaryScan) MaterialTheme.colorScheme.outlineVariant else MaterialTheme.colorScheme.primary, shape)
.clickable(enabled = enabled, role = Role.Button, onClick = onOpenAccount)
.semantics(mergeDescendants = true) {
contentDescription = accountLabel
@@ -79,7 +82,7 @@ internal fun SignedOutConnectionActions(
accountLabel,
fontSize = fontSize.sp,
fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.onPrimary,
+ color = if (primaryScan) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onPrimary,
)
}
}
diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/ConnectView.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/ConnectView.kt
index d35c7f363a..503f214bdd 100644
--- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/ConnectView.kt
+++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/ConnectView.kt
@@ -1,5 +1,9 @@
package com.openbitfun.mobile.app.ui.remote
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -55,12 +59,13 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import com.openbitfun.mobile.app.infrastructure.camera.InlineQrScanner
+import com.openbitfun.mobile.app.ui.common.ConnectionSheetHeader
+import com.openbitfun.mobile.app.ui.common.ConnectionSheetFooter
+import com.openbitfun.mobile.app.ui.common.connectionSheetTextStyle
import com.openbitfun.mobile.app.R
import com.openbitfun.mobile.app.ui.common.SignedOutConnectionActions
import com.openbitfun.mobile.app.ui.theme.openBitFunColors
-import com.google.mlkit.vision.barcode.common.Barcode
-import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions
-import com.google.mlkit.vision.codescanner.GmsBarcodeScanning
internal const val CONNECT_TEST_TAG: String = "connect"
internal const val CONNECT_MANUAL_TEST_TAG: String = "connect-manual"
@@ -73,9 +78,7 @@ internal const val CONNECT_SUBMIT_TEST_TAG: String = "connect-submit"
* Scanning is the way in and typing is the fallback, as on HarmonyOS: the link
* is long, opaque and easy to mistype, so the intro step offers the camera and
* keeps the fields out of sight until someone asks for them. HarmonyOS draws its
- * own camera preview. Android delegates capture to Play Services, but keeps the
- * matching scan step underneath it so cancellation returns to the same manual
- * fallback and back-navigation structure.
+ * own camera preview; Android uses a lifecycle-bound inline camera adapter.
*/
@Composable
internal fun ConnectView(
@@ -89,51 +92,30 @@ internal fun ConnectView(
var manual by rememberSaveable { mutableStateOf(false) }
var scanning by rememberSaveable { mutableStateOf(startScanning) }
var url by rememberSaveable { mutableStateOf("") }
- var scanFailed by rememberSaveable { mutableStateOf(false) }
-
- val context = LocalContext.current
- val scanner = remember(context) {
- GmsBarcodeScanning.getClient(
- context,
- GmsBarcodeScannerOptions.Builder()
- .setBarcodeFormats(Barcode.FORMAT_QR_CODE)
- .enableAutoZoom()
- .build(),
- )
- }
- val scan = {
- scanFailed = false
- scanner.startScan()
- .addOnSuccessListener { barcode ->
- val scanned = barcode.rawValue.orEmpty().trim()
- if (scanned.isNotEmpty()) {
- url = scanned
- onSubmit(scanned)
- }
- }
- .addOnFailureListener { scanFailed = true }
- .addOnCanceledListener { scanFailed = true }
- Unit
- }
+ var scanError by remember { mutableStateOf(null) }
LaunchedEffect(startScanning) {
- if (startScanning) {
- scanning = true
- scan()
- onScanStarted()
- }
+ if (startScanning) { scanning = true; onScanStarted() }
+ }
+
+ BackHandler(enabled = manual || scanning) {
+ if (manual) manual = false else if (startScanning) onBack() else scanning = false
}
Box(modifier = modifier.fillMaxSize().testTag(CONNECT_TEST_TAG)) {
Column(
modifier = Modifier
.fillMaxSize()
- .padding(top = 8.dp, bottom = 34.dp),
+ .then(if (!scanning) Modifier.verticalScroll(rememberScrollState()) else Modifier)
+ .then(if (!scanning) Modifier.padding(top = 8.dp, bottom = 28.dp) else Modifier),
) {
if (scanning) {
ScanPairing(
- scanFailed = scanFailed,
+ scanError = scanError,
+ paused = manual,
+ onDetected = { url = it; onSubmit(it) },
+ onScanError = { scanError = it },
connecting = false,
- onBack = { scanning = false },
+ onBack = onBack,
onManual = { manual = true },
)
} else {
@@ -141,7 +123,7 @@ internal fun ConnectView(
connecting = false,
onScan = {
scanning = true
- scan()
+ scanError = null
},
onBack = onBack,
onOpenAccount = onOpenAccount,
@@ -169,7 +151,7 @@ private fun ColumnScope.IntroPairing(
onOpenAccount: () -> Unit,
) {
Box {
- Hero()
+ Hero(height = 250.dp)
Surface(
onClick = onBack,
shape = androidx.compose.foundation.shape.CircleShape,
@@ -191,93 +173,70 @@ private fun ColumnScope.IntroPairing(
}
Column(
modifier = Modifier
- .weight(1f)
.fillMaxWidth()
- .offset(y = (-10).dp),
+ .offset(y = (-24).dp),
horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(15.dp),
+ verticalArrangement = Arrangement.spacedBy(18.dp),
) {
ConnectDesktopGlyph()
Text(
- stringResource(R.string.connect_choose_connection),
- fontSize = 24.sp,
- lineHeight = 30.sp,
- fontWeight = FontWeight.Bold,
+ stringResource(R.string.sidebar_connect_desktop),
+ style = MaterialTheme.typography.displayLarge,
textAlign = TextAlign.Center,
)
+ SignedOutConnectionActions(
+ scanLabel = stringResource(R.string.sidebar_scan_to_connect),
+ accountLabel = stringResource(R.string.sidebar_sign_in),
+ onScan = onScan,
+ onOpenAccount = onOpenAccount,
+ modifier = Modifier.fillMaxWidth(0.82f),
+ enabled = !connecting,
+ buttonHeight = 58.dp,
+ spacing = 18.dp,
+ fontSize = 16,
+ primaryScan = true,
+ )
}
-
- SignedOutConnectionActions(
- scanLabel = stringResource(R.string.sidebar_scan_to_connect),
- accountLabel = stringResource(R.string.sidebar_sign_in),
- onScan = onScan,
- onOpenAccount = onOpenAccount,
- modifier = Modifier
- .align(Alignment.CenterHorizontally)
- .padding(bottom = 14.dp)
- .fillMaxWidth(0.82f),
- enabled = !connecting,
- buttonHeight = 58.dp,
- spacing = 12.dp,
- fontSize = 20,
- )
}
@Composable
private fun ColumnScope.ScanPairing(
- scanFailed: Boolean,
- connecting: Boolean,
- onBack: () -> Unit,
- onManual: () -> Unit,
+ scanError: Int?, paused: Boolean,
+ onDetected: (String) -> Unit, onScanError: (Int?) -> Unit,
+ connecting: Boolean, onBack: () -> Unit, onManual: () -> Unit,
) {
- Box {
- Hero(height = 252.dp)
- Surface(
- onClick = onBack,
- shape = androidx.compose.foundation.shape.CircleShape,
- color = MaterialTheme.colorScheme.surfaceVariant,
- modifier = Modifier.align(Alignment.TopStart).padding(start = 28.dp, top = 18.dp).size(48.dp),
- ) {
- Box(contentAlignment = Alignment.Center) {
- Icon(
- painterResource(R.drawable.ic_symbol_chevron_left),
- contentDescription = stringResource(R.string.common_back),
- tint = MaterialTheme.colorScheme.onSurface,
- modifier = Modifier.size(21.dp),
- )
+ ConnectionSheetHeader(onBack)
+ Box(Modifier.weight(1f).fillMaxWidth().padding(start = 20.dp, end = 20.dp, bottom = 18.dp),
+ contentAlignment = Alignment.Center) {
+ Column(Modifier.widthIn(max = 520.dp).fillMaxWidth().verticalScroll(rememberScrollState()),
+ horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(stringResource(R.string.connect_scan_title), style = MaterialTheme.typography.displayMedium.connectionSheetTextStyle(),
+ textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth())
+ Text(stringResource(R.string.connect_scan_body), style = MaterialTheme.typography.bodyMedium.connectionSheetTextStyle(),
+ color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 24.dp))
+ CameraFrame(paused, onDetected, onScanError)
+ Row(Modifier.padding(top = 18.dp).fillMaxWidth().height(30.dp)
+ .background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(15.dp))
+ .padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Box(Modifier.size(8.dp).background(MaterialTheme.colorScheme.outline,
+ androidx.compose.foundation.shape.CircleShape))
+ Text(stringResource(R.string.connect_scan_hint), style = MaterialTheme.typography.bodySmall.connectionSheetTextStyle(),
+ color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1,
+ overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis)
+ }
+ if (scanError != null) {
+ Text(stringResource(scanError), style = MaterialTheme.typography.bodySmall.connectionSheetTextStyle(),
+ color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center,
+ modifier = Modifier.padding(top = 14.dp).fillMaxWidth()
+ .background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(16.dp))
+ .padding(horizontal = 14.dp, vertical = 12.dp))
}
}
}
- Column(
- modifier = Modifier.weight(1f).fillMaxWidth().offset(y = (-50).dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(22.dp),
- ) {
- CameraFrame()
- Text(
- stringResource(R.string.connect_scan_title),
- fontSize = 24.sp,
- lineHeight = 30.sp,
- fontWeight = FontWeight.Bold,
- textAlign = TextAlign.Center,
- modifier = Modifier.fillMaxWidth(),
- )
- if (scanFailed) {
- Centered(stringResource(R.string.connect_scan_failed), fontSize = 13.sp, lineHeight = 18.sp)
- }
- }
- OutlinedButton(
- onClick = onManual,
- enabled = !connecting,
- modifier = Modifier
- .align(Alignment.CenterHorizontally)
- .fillMaxWidth(0.78f)
- .height(58.dp)
- .testTag(CONNECT_MANUAL_TEST_TAG),
- shape = RoundedCornerShape(35.dp),
- ) {
- Text(stringResource(R.string.connect_switch_manual), fontSize = 20.sp, fontWeight = FontWeight.Bold)
- }
+ ConnectionSheetFooter(stringResource(R.string.connect_switch_manual), enabled = !connecting,
+ modifier = Modifier.testTag(CONNECT_MANUAL_TEST_TAG), onClick = onManual)
}
@Composable
@@ -439,50 +398,32 @@ private fun Hero(height: Dp = 282.dp) {
}
@Composable
-private fun CameraFrame() {
- val accent = MaterialTheme.colorScheme.primary
- Box(
- modifier = Modifier
- .size(282.dp)
- .clip(RoundedCornerShape(40.dp))
- .background(openBitFunColors.shadowMedium),
- ) {
- ScanCorner(accent, Alignment.TopStart, true, true)
- ScanCorner(accent, Alignment.TopEnd, false, true)
- ScanCorner(accent, Alignment.BottomStart, true, false)
- ScanCorner(accent, Alignment.BottomEnd, false, false)
- }
-}
-
-@Composable
-private fun BoxScope.ScanCorner(color: Color, alignment: Alignment, left: Boolean, top: Boolean) {
- Box(
- Modifier
- .align(alignment)
- .padding(
- start = if (left) 32.dp else 0.dp,
- end = if (left) 0.dp else 32.dp,
- top = if (top) 32.dp else 0.dp,
- bottom = if (top) 0.dp else 32.dp,
- )
- .size(64.dp),
- ) {
- Box(
- Modifier
- .align(if (top) Alignment.TopCenter else Alignment.BottomCenter)
- .fillMaxWidth()
- .height(4.dp)
- .clip(RoundedCornerShape(2.dp))
- .background(color),
- )
- Box(
- Modifier
- .align(if (left) Alignment.CenterStart else Alignment.CenterEnd)
- .width(4.dp)
- .height(64.dp)
- .clip(RoundedCornerShape(2.dp))
- .background(color),
- )
+private fun CameraFrame(paused: Boolean, onDetected: (String) -> Unit, onScanError: (Int?) -> Unit) {
+ val colors = openBitFunColors
+ Box(Modifier.size(248.dp).clip(RoundedCornerShape(28.dp))
+ .border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(28.dp))) {
+ InlineQrScanner(Modifier.fillMaxSize(), paused, onDetected, { onScanError(null) },
+ { onScanError(R.string.connect_camera_permission_denied) },
+ { onScanError(R.string.connect_camera_unavailable) })
+ Canvas(Modifier.fillMaxSize()) {
+ drawRect(colors.shadowMedium)
+ val inset = 20.dp.toPx()
+ val size = 56.dp.toPx()
+ val stroke = 4.dp.toPx()
+ val length = 36.dp.toPx()
+ for (right in listOf(false, true)) for (bottom in listOf(false, true)) {
+ val x = if (right) this.size.width - inset - size else inset
+ val y = if (bottom) this.size.height - inset - size else inset
+ drawRoundRect(colors.scanAccent,
+ androidx.compose.ui.geometry.Offset(x + if (right) size - length else 0f,
+ y + if (bottom) size - stroke else 0f),
+ androidx.compose.ui.geometry.Size(length, stroke), androidx.compose.ui.geometry.CornerRadius(2.dp.toPx()))
+ drawRoundRect(colors.scanAccent,
+ androidx.compose.ui.geometry.Offset(x + if (right) size - stroke else 0f,
+ y + if (bottom) size - length else 0f),
+ androidx.compose.ui.geometry.Size(stroke, length), androidx.compose.ui.geometry.CornerRadius(2.dp.toPx()))
+ }
+ }
}
}
diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/PairingScreen.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/PairingScreen.kt
index c07bce6a0c..f83a43a2dd 100644
--- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/PairingScreen.kt
+++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/PairingScreen.kt
@@ -1,5 +1,6 @@
package com.openbitfun.mobile.app.ui.remote
+import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -12,6 +13,8 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Surface
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
@@ -25,6 +28,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.font.FontWeight
@@ -314,11 +318,48 @@ private fun RemoteCompactHome(
}
}
+/** The remote landing page precedes the connection chooser, as on HarmonyOS. */
+@Composable
+internal fun DisconnectedRemoteHome(
+ onOpenSidebar: (() -> Unit)?,
+ onConnect: () -> Unit,
+) {
+ Column(Modifier.fillMaxSize().testTag("remote-disconnected-home")) {
+ RemoteShellHeader(onOpenSidebar = onOpenSidebar, onOpenRemoteSettings = null)
+ Column(
+ modifier = Modifier.weight(1f).fillMaxWidth()
+ .padding(start = 20.dp, end = 20.dp, bottom = 48.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Surface(
+ shape = RoundedCornerShape(24.dp),
+ border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
+ modifier = Modifier.size(74.dp),
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Icon(painterResource(R.drawable.ic_symbol_desktop), contentDescription = null,
+ modifier = Modifier.size(42.dp))
+ }
+ }
+ Text(stringResource(R.string.sidebar_connect_desktop),
+ style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center)
+ Text(stringResource(R.string.remote_connect_description),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center)
+ Button(onClick = onConnect, shape = RoundedCornerShape(22.dp),
+ modifier = Modifier.width(136.dp).height(44.dp)) {
+ Text(stringResource(R.string.pairing_connect), style = MaterialTheme.typography.titleSmall)
+ }
+ }
+ }
+}
+
@Composable
private fun RemoteShellHeader(
onOpenSidebar: (() -> Unit)?,
subtitle: String = "",
- onOpenRemoteSettings: () -> Unit,
+ onOpenRemoteSettings: (() -> Unit)?,
) {
val hasSubtitle = subtitle.isNotBlank()
Row(
@@ -359,13 +400,13 @@ private fun RemoteShellHeader(
)
}
}
- CircleControl(
+ if (onOpenRemoteSettings != null) CircleControl(
icon = R.drawable.ic_symbol_gearshape,
glyphSize = 19,
contentDescription = stringResource(R.string.remote_settings_title),
onClick = onOpenRemoteSettings,
modifier = Modifier,
- )
+ ) else Box(Modifier.size(com.openbitfun.mobile.app.ui.theme.generated.MobileDesignGeometry.ControlTouchSize))
}
}
diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/MobileScreen.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/MobileScreen.kt
index 8a4634df45..a563501d93 100644
--- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/MobileScreen.kt
+++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/MobileScreen.kt
@@ -50,6 +50,7 @@ import com.openbitfun.mobile.app.ui.common.AdaptiveModalSurface
import com.openbitfun.mobile.app.ui.remote.AccountRemoteScreen
import com.openbitfun.mobile.app.ui.remote.ConnectAccountDeviceScreen
import com.openbitfun.mobile.app.ui.remote.FilePreviewSurface
+import com.openbitfun.mobile.app.ui.remote.DisconnectedRemoteHome
import com.openbitfun.mobile.app.ui.remote.PairingScreen
import com.openbitfun.mobile.app.ui.settings.GeneralSettingsScreen
import com.openbitfun.mobile.app.ui.settings.SettingsScreen
@@ -141,12 +142,12 @@ internal fun MobileScreen() {
com.openbitfun.mobile.core.feature.account.AccountDeviceLinkStatus.READY -> {
pendingDeviceLink = null
accountViewModel.selectDevice(result.deviceId!!)
- shell.closeRemoteScanner()
+ shell.closeRemoteConnect()
}
com.openbitfun.mobile.core.feature.account.AccountDeviceLinkStatus.SIGN_IN_REQUIRED -> {
pendingDeviceLink = url
accountViewModel.dispatch(com.openbitfun.mobile.core.feature.account.AccountIntent.SelectRelay(result.relayUrl!!))
- shell.closeRemoteScanner()
+ shell.closeRemoteConnect()
shell.openAccount()
}
else -> {
@@ -237,6 +238,7 @@ internal fun MobileScreen() {
adaptiveLayoutInput,
SettingsSheetKind.SETTINGS,
)
+ val connectPlacement = SettingsPlacementPolicy.resolve(adaptiveLayoutInput, SettingsSheetKind.CONNECT)
val sessionDetailsPlacement = SettingsPlacementPolicy.resolve(
adaptiveLayoutInput,
SettingsSheetKind.SESSION_DETAILS,
@@ -368,31 +370,7 @@ internal fun MobileScreen() {
) { insets ->
Box(Modifier.padding(insets)) {
when (shell.surface) {
- MobileSurface.REMOTE -> if (shell.remoteScanRequested) {
- PairingScreen(
- onDeviceLink = connectDeviceLink,
- modifier = Modifier,
- settingsPlacement = settingsPlacement,
- sessionDetailsPlacement = sessionDetailsPlacement,
- viewSettingsPlacement = remoteViewSettingsPlacement,
- onOpenRemoteSettings = { shell.openSettings(SettingsMode.REMOTE) },
- onOpenSidebar = if (showMenu) {
- { compactDrawerOpen = true }
- } else {
- null
- },
- onBack = {
- shell.closeRemoteScanner()
- shell.openRemoteConnect()
- },
- onOpenAccount = {
- shell.closeRemoteScanner()
- shell.openAccount()
- },
- compact = !wide,
- startScanning = true,
- )
- } else when (controlSummary.source) {
+ MobileSurface.REMOTE -> when (controlSummary.source) {
RemoteControlSource.ACCOUNT_DEVICE -> AccountRemoteScreen(
remoteState = accountRemoteState,
workspaceState = accountWorkspaceState,
@@ -441,27 +419,11 @@ internal fun MobileScreen() {
modifier = Modifier,
)
} else {
- PairingScreen(
- onDeviceLink = connectDeviceLink,
- modifier = Modifier,
- settingsPlacement = settingsPlacement,
- sessionDetailsPlacement = sessionDetailsPlacement,
- viewSettingsPlacement = remoteViewSettingsPlacement,
- onOpenRemoteSettings = { shell.openSettings(SettingsMode.REMOTE) },
- onOpenSidebar = if (showMenu) {
- { compactDrawerOpen = true }
- } else {
- null
- },
- onBack = { shell.openRemoteConnect() },
- onOpenAccount = { shell.openAccount() },
- compact = !wide,
- requestedSessionId = shell.remoteSessionId,
- creatingSession = shell.remoteCreating,
- onOpenSession = shell::openRemoteSession,
- onCreateSession = shell::createRemoteSession,
- onRemoteHome = shell::closeRemoteSession,
+ DisconnectedRemoteHome(
+ onOpenSidebar = if (showMenu) { { compactDrawerOpen = true } } else null,
+ onConnect = shell::openRemoteConnect,
)
+
}
}
}
@@ -609,6 +571,34 @@ internal fun MobileScreen() {
}
}
+ AdaptiveModalSurface(
+ visible = shell.remoteConnectOpen,
+ edgeToEdgeContent = true,
+ placement = connectPlacement,
+ onDismissRequest = shell::closeRemoteConnect,
+ ) { sheetModifier ->
+ if (readyAccount != null && !shell.remoteScanRequested) {
+ ConnectAccountDeviceScreen(
+ state = readyAccount,
+ onBack = shell::closeRemoteConnect,
+ onRefresh = { accountViewModel.dispatch(AccountIntent.RefreshDevices) },
+ onSelect = { shell.closeRemoteConnect(); accountViewModel.selectDevice(it) },
+ onOpenScanner = shell::openRemoteScanner,
+ modifier = sheetModifier,
+ )
+ } else PairingScreen(
+ onDeviceLink = connectDeviceLink,
+ modifier = sheetModifier,
+ settingsPlacement = settingsPlacement,
+ sessionDetailsPlacement = sessionDetailsPlacement,
+ viewSettingsPlacement = remoteViewSettingsPlacement,
+ onOpenRemoteSettings = { shell.openSettings(SettingsMode.REMOTE) },
+ onBack = shell::closeRemoteConnect,
+ onOpenAccount = { shell.closeRemoteConnect(); shell.openAccount() },
+ startScanning = shell.remoteScanRequested,
+
+ )
+ }
AdaptiveModalSurface(
visible = shell.showSettings,
placement = settingsPlacement,
@@ -617,6 +607,8 @@ internal fun MobileScreen() {
)
AdaptiveModalSurface(
visible = shell.showAccount,
+ fitContent = readyAccount == null,
+ edgeToEdgeContent = readyAccount != null,
placement = settingsPlacement,
onDismissRequest = shell::dismissAccount,
) { modifier ->
diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/theme/Theme.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/theme/Theme.kt
index 4c3856fede..b7d1abb357 100644
--- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/theme/Theme.kt
+++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/theme/Theme.kt
@@ -21,10 +21,7 @@ import com.openbitfun.mobile.app.ui.theme.generated.MobileDesignTypography
* literals, so aligning the two clients is a matter of what those roles resolve
* to — nothing below this file had to change to get the new colours.
*
- * The tokens Material has no role for live in [OpenBitFunColors]. Only the ones a
- * screen actually paints with are carried over; `connect_scan_accent` stays
- * behind because it decorates a viewfinder HarmonyOS draws itself and Play
- * Services draws for us. A colour nothing reads is a colour nobody maintains.
+ * Additional semantic colors cover app-owned surfaces, including the inline scanner.
*/
private val LightTokens = MobileDesignColors.Light
private val DarkTokens = MobileDesignColors.Dark
@@ -132,6 +129,7 @@ internal data class OpenBitFunColors(
val mediaScrim: Color,
val mediaControlBackground: Color,
val toastBackground: Color,
+ val scanAccent: Color,
val shadowSubtle: Color,
val shadowMedium: Color,
val shadowStrong: Color,
@@ -172,6 +170,7 @@ private val LightExtras = OpenBitFunColors(
mediaScrim = LightTokens.MediaScrim,
mediaControlBackground = LightTokens.MediaControlBackground,
toastBackground = LightTokens.ToastBackground,
+ scanAccent = LightTokens.ConnectScanAccent,
shadowSubtle = LightTokens.ShadowSubtle,
shadowMedium = LightTokens.ShadowMedium,
shadowStrong = LightTokens.ShadowStrong,
@@ -202,6 +201,7 @@ private val DarkExtras = OpenBitFunColors(
mediaScrim = DarkTokens.MediaScrim,
mediaControlBackground = DarkTokens.MediaControlBackground,
toastBackground = DarkTokens.ToastBackground,
+ scanAccent = DarkTokens.ConnectScanAccent,
shadowSubtle = DarkTokens.ShadowSubtle,
shadowMedium = DarkTokens.ShadowMedium,
shadowStrong = DarkTokens.ShadowStrong,
diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/theme/generated/MobileDesignTokens.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/theme/generated/MobileDesignTokens.kt
index 91c808731d..d169a23b39 100644
--- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/theme/generated/MobileDesignTokens.kt
+++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/theme/generated/MobileDesignTokens.kt
@@ -147,6 +147,9 @@ internal object MobileDesignGeometry {
val ComposerModelSelectorRowRadius = 9.dp
val ComposerModelSelectorRowGap = 6.dp
val SheetTopRadius = 28.dp
+ val LoginSheetMaxWidth = 560.dp
+ val LoginSheetOuterMargin = 8.dp
+ val LoginSheetBodyMinHeight = 142.dp
val SheetSideRadius = 28.dp
val SheetHorizontalPadding = 20.dp
val SheetHeaderHeight = 56.dp
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 1085817b6c..fa4766330f 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
@@ -25,7 +25,7 @@
深色
账号
使用 GitHub 登录
- 使用 GitHub 登录,连接你的电脑并远程操作会话。
+ 使用 GitHub 登录并连接自己的电脑。\n任务和模型配置保留在被控电脑上。
登录服务器
OpenBitFun 用户名
OpenBitFun 密码
@@ -136,7 +136,7 @@
配对码
配对
改为手动配对
- 无法打开相机,请检查相机权限,或改为输入链接。
+ 未能完成扫码,请返回重试,或改为输入连接链接。
返回
已连接
@@ -444,4 +444,9 @@
稍后
开启通知
允许 OpenBitFun 在任务完成时发送通知。你可以稍后在系统设置中更改。
+ 扫描桌面端显示的二维码,开始远程处理任务。
+ 在 OpenBitFun 桌面端点击「连接移动端」\n扫描二维码完成连接
+ 需要相机权限才能扫码,请在系统设置中允许 OpenBitFun 访问相机,或改为手动配对。
+ 无法打开相机,请检查权限后重试,或改为手动配对。
+ 扫描二维码或粘贴远程连接链接后会显示桌面端连接状态。
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 97277aaf34..28dfec51a8 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
@@ -26,7 +26,7 @@
Dark
Account
Sign in with GitHub
- Sign in with GitHub to connect to your computers and control their sessions.
+ Sign in with GitHub to connect to your computers.\nTasks and model settings stay on the controlled computer.
Sign-in server
OpenBitFun username
OpenBitFun password
@@ -140,7 +140,7 @@
Pairing code
Pair
Pair manually instead
- The camera could not be opened. Check the camera permission, or enter the link instead.
+ Scanning could not be completed. Go back to retry, or enter the connection link.
Back
Connected
@@ -459,4 +459,9 @@
Later
Enable notifications
Allow OpenBitFun to notify you when tasks finish. You can change this later in system settings.
+ Scan the QR code shown on your desktop to start working remotely.
+ On OpenBitFun Desktop, tap Connect mobile\nthen scan the QR code
+ Camera access is required to scan. Allow OpenBitFun to use the camera in system settings, or pair manually.
+ The camera could not be opened. Check permissions and retry, or pair manually.
+ Scanning a QR code or pasting a remote connection link will show the desktop connection status.
diff --git a/src/apps/mobile/android/app/src/test/kotlin/com/openbitfun/mobile/app/state/AppShellStateTest.kt b/src/apps/mobile/android/app/src/test/kotlin/com/openbitfun/mobile/app/state/AppShellStateTest.kt
new file mode 100644
index 0000000000..b76a7c3e6e
--- /dev/null
+++ b/src/apps/mobile/android/app/src/test/kotlin/com/openbitfun/mobile/app/state/AppShellStateTest.kt
@@ -0,0 +1,52 @@
+package com.openbitfun.mobile.app.state
+
+import androidx.compose.runtime.saveable.SaverScope
+import org.junit.Assert.*
+import org.junit.Test
+
+class AppShellStateTest {
+ private fun state() = AppShellState(
+ MobileSurface.REMOTE, false, SettingsMode.GENERAL, false, false, false, "",
+ )
+
+ @Test fun disconnectedHomeIsSeparateFromConnectionChooser() {
+ val shell = state()
+ assertFalse(shell.remoteConnectOpen)
+ shell.openRemoteConnect()
+ assertTrue(shell.remoteConnectOpen)
+ assertFalse(shell.remoteScanRequested)
+ shell.openRemoteScanner()
+ shell.closeRemoteScanner()
+ assertTrue(shell.remoteConnectOpen)
+ assertFalse(shell.remoteScanRequested)
+ shell.closeRemoteConnect()
+ assertFalse(shell.remoteConnectOpen)
+ }
+
+ @Test fun selectingADeviceOrSessionClosesConnectionFlow() {
+ val shell = state()
+ shell.openRemoteScanner()
+ shell.closeRemoteSession()
+ assertFalse(shell.remoteScanRequested)
+ assertFalse(shell.remoteConnectOpen)
+ shell.openRemoteConnect()
+ shell.openRemoteSession("session")
+ assertEquals("session", shell.remoteSessionId)
+ assertFalse(shell.remoteConnectOpen)
+ }
+
+ @Test fun restoreAcceptsOldSavedStateAndRetainsNewConnectionRoute() {
+ val legacy = listOf("REMOTE", false, "GENERAL", false, false, false, "", null, false, false)
+ assertFalse(AppShellState.Saver.restore(legacy)!!.remoteConnectOpen)
+ val legacyScanner = legacy.dropLast(1) + true
+ assertTrue(AppShellState.Saver.restore(legacyScanner)!!.remoteConnectOpen)
+ val shell = state().apply { openRemoteScanner() }
+ val scope = object : SaverScope {
+ override fun canBeSaved(value: Any): Boolean = true
+ }
+ val saved = with(AppShellState.Saver) { scope.save(shell) }!!
+ val restored = AppShellState.Saver.restore(saved)!!
+ assertTrue(restored.remoteConnectOpen)
+ assertTrue(restored.remoteScanRequested)
+ }
+}
diff --git a/src/apps/mobile/design-system/components/mobile-components.json b/src/apps/mobile/design-system/components/mobile-components.json
index b37a8d059f..66594e047d 100644
--- a/src/apps/mobile/design-system/components/mobile-components.json
+++ b/src/apps/mobile/design-system/components/mobile-components.json
@@ -50,6 +50,13 @@
"tokens": ["scrim", "sheet_top_radius", "sheet_side_radius", "sheet_horizontal_padding", "sheet_header_height", "sheet_action_height", "card", "line", "structure"],
"platformNotes": "Use each platform's native modal lifecycle, accessibility focus, and back gesture; placement comes from the shared adaptive policy and visual geometry comes from these tokens."
},
+ "account_login_sheet": {
+ "purpose": "Offers GitHub sign-in in a compact bottom sheet without empty full-screen space.",
+ "anatomy": ["close_header", "title", "supporting_copy", "optional_error", "primary_action"],
+ "states": ["signed_out", "authorizing", "error"],
+ "tokens": ["sheet_top_radius", "sheet_horizontal_padding", "sheet_header_height", "sheet_action_height", "display_medium", "body_medium"],
+ "platformNotes": "Use a bottom-aligned modal paper on compact and wide hosts: width min(available width - 16, 560), 8-unit margins outside system safe areas, 28-unit circular corners, no button or panel shadow. Header 56, top-aligned body at least 142, footer 10 + 48 + 24 give a 280-unit default height. Body grows for localized text, accessibility text and errors; oversized content scrolls. Close glyph uses round-capped 1.5-unit diagonal strokes from (2,2) to (16,16) and (16,2) to (2,16) within an 18-unit box, centered in a 48-unit target. Account profiles retain their separate adaptive settings presentation."
+ },
"action_popover": {
"purpose": "Presents a short, anchored set of conversation, session, model, or project actions.",
"anatomy": ["section_label", "action_rows", "optional_divider", "anchored_paper_surface"],
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 5f13c3b4d0..b4a0e3cd11 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
@@ -288,6 +288,9 @@ export const mobileTokens = {
"composer_model_selector_row_radius": 9,
"composer_model_selector_row_gap": 6,
"sheet_top_radius": 28,
+ "login_sheet_max_width": 560,
+ "login_sheet_outer_margin": 8,
+ "login_sheet_body_min_height": 142,
"sheet_side_radius": 28,
"sheet_horizontal_padding": 20,
"sheet_header_height": 56,
@@ -513,6 +516,30 @@ export const mobileComponents = {
],
"platformNotes": "Use each platform's native modal lifecycle, accessibility focus, and back gesture; placement comes from the shared adaptive policy and visual geometry comes from these tokens."
},
+ "account_login_sheet": {
+ "purpose": "Offers GitHub sign-in in a compact bottom sheet without empty full-screen space.",
+ "anatomy": [
+ "close_header",
+ "title",
+ "supporting_copy",
+ "optional_error",
+ "primary_action"
+ ],
+ "states": [
+ "signed_out",
+ "authorizing",
+ "error"
+ ],
+ "tokens": [
+ "sheet_top_radius",
+ "sheet_horizontal_padding",
+ "sheet_header_height",
+ "sheet_action_height",
+ "display_medium",
+ "body_medium"
+ ],
+ "platformNotes": "Use a bottom-aligned modal paper on compact and wide hosts: width min(available width - 16, 560), 8-unit margins outside system safe areas, 28-unit circular corners, no button or panel shadow. Header 56, top-aligned body at least 142, footer 10 + 48 + 24 give a 280-unit default height. Body grows for localized text, accessibility text and errors; oversized content scrolls. Close glyph uses round-capped 1.5-unit diagonal strokes from (2,2) to (16,16) and (16,2) to (2,16) within an 18-unit box, centered in a 48-unit target. Account profiles retain their separate adaptive settings presentation."
+ },
"action_popover": {
"purpose": "Presents a short, anchored set of conversation, session, model, or project actions.",
"anatomy": [
diff --git a/src/apps/mobile/design-system/tokens/mobile-tokens.json b/src/apps/mobile/design-system/tokens/mobile-tokens.json
index 458acb7def..5b5e46b10d 100644
--- a/src/apps/mobile/design-system/tokens/mobile-tokens.json
+++ b/src/apps/mobile/design-system/tokens/mobile-tokens.json
@@ -94,6 +94,9 @@
"composer_model_selector_row_radius": 9,
"composer_model_selector_row_gap": 6,
"sheet_top_radius": 28,
+ "login_sheet_max_width": 560,
+ "login_sheet_outer_margin": 8,
+ "login_sheet_body_min_height": 142,
"sheet_side_radius": 28,
"sheet_horizontal_padding": 20,
"sheet_header_height": 56,
diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/generated/MobileDesignTokens.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/generated/MobileDesignTokens.ets
index 68bafa877a..526ee74b0d 100644
--- a/src/apps/mobile/harmonyos/entry/src/main/ets/generated/MobileDesignTokens.ets
+++ b/src/apps/mobile/harmonyos/entry/src/main/ets/generated/MobileDesignTokens.ets
@@ -114,6 +114,9 @@ export class MobileDesignGeometry {
static readonly composerModelSelectorRowRadius: number = 9;
static readonly composerModelSelectorRowGap: number = 6;
static readonly sheetTopRadius: number = 28;
+ static readonly loginSheetMaxWidth: number = 560;
+ static readonly loginSheetOuterMargin: number = 8;
+ static readonly loginSheetBodyMinHeight: number = 142;
static readonly sheetSideRadius: number = 28;
static readonly sheetHorizontalPadding: number = 20;
static readonly sheetHeaderHeight: number = 56;
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 f9b1406007..708b2dfb55 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
@@ -255,7 +255,7 @@ export const EN_US_MESSAGES: [string, string][] = [
['remote.settings.accountSignedInBody', 'This connection is verified as account {0}. The password is not saved on the phone.'],
['remote.settings.accountNotSignedInBody', 'If the desktop asks for account verification while scanning, the phone completes it for this pairing.'],
['remote.settings.accountLoginTitle', 'Sign in with GitHub'],
- ['remote.settings.accountLoginBody', 'Sign in with GitHub to connect to your computers. Tasks and model settings stay on the controlled computer.'],
+ ['remote.settings.accountLoginBody', 'Sign in with GitHub to connect to your computers.\nTasks and model settings stay on the controlled computer.'],
['remote.settings.loginServer', 'Sign-in server'],
['remote.settings.accountSignIn', 'Sign in with GitHub'],
['remote.settings.accountSigningIn', 'Signing in…'],
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 1322553bd3..35f1802977 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
@@ -255,7 +255,7 @@ export const ZH_CN_MESSAGES: [string, string][] = [
['remote.settings.accountSignedInBody', '当前连接已通过账号 {0} 验证。密码不会保存到手机。'],
['remote.settings.accountNotSignedInBody', '扫码连接时,如果桌面端要求账号验证,手机会在本次配对中完成认证。'],
['remote.settings.accountLoginTitle', '使用 GitHub 登录'],
- ['remote.settings.accountLoginBody', '使用 GitHub 登录并连接自己的电脑。任务和模型配置保留在被控电脑上。'],
+ ['remote.settings.accountLoginBody', '使用 GitHub 登录并连接自己的电脑。\n任务和模型配置保留在被控电脑上。'],
['remote.settings.loginServer', '登录服务器'],
['remote.settings.accountSignIn', '使用 GitHub 登录'],
['remote.settings.accountSigningIn', '正在登录…'],
diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppActionButton.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppActionButton.ets
index 82dbe85060..a15067c309 100644
--- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppActionButton.ets
+++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppActionButton.ets
@@ -26,6 +26,7 @@ export struct AppActionButton {
@Param style: AppActionStyle = AppActionStyle.Secondary;
@Param actionHeight: number = MobileDesignGeometry.sheetActionHeight;
@Param isEnabled: boolean = true;
+ @Param elevated: boolean = true;
@Event onAction: () => void = () => {};
build() {
@@ -39,9 +40,9 @@ export struct AppActionButton {
.border({ width: this.style === AppActionStyle.Secondary ? 1 : 0, color: LINE })
.borderRadius(this.actionHeight / 2)
.shadow({
- radius: this.hasElevation() ? 14 : 0,
+ radius: this.elevated && this.hasElevation() ? 14 : 0,
color: this.isFilled() ? SHADOW_SUBTLE : SHADOW_FAINT,
- offsetY: this.hasElevation() ? 5 : 0
+ offsetY: this.elevated && this.hasElevation() ? 5 : 0
})
.opacity(this.isEnabled ? 1 : 0.32)
.enabled(this.isEnabled)
diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets
index fee810df12..dc4de7d6fa 100644
--- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets
+++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets
@@ -132,6 +132,8 @@ export struct AppRootPresentation {
shellState: this.shellState,
useWideLayout: this.isWideLayout(),
settingsPlacement: this.settingsPlacement,
+ compactAccountLogin: this.shellState.settingsMode === 'account' &&
+ this.remotePageState.accountUserId.trim().length === 0,
connectPlacement: this.connectPlacement,
content: () => { this.NavigationContent(); },
sidebar: () => { this.SidebarContent(); },
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 d0ffa737ef..41a9860e4e 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
@@ -1,3 +1,4 @@
+import { MobileDesignGeometry } from '../../generated/MobileDesignTokens';
import { AppShellState } from '../state/AppShellState';
import { ConversationLayoutPolicy } from '../policy/ConversationLayoutPolicy';
import {
@@ -9,6 +10,7 @@ import {
import { AdaptiveSheetOptions } from './AdaptiveSheetOptions';
import {
PAGE_BG,
+ SCRIM,
SHELL_SCRIM,
TRANSPARENT,
} from './Theme';
@@ -19,6 +21,7 @@ const WIDE_SHEET_RADIUS: number = 30;
export struct AppShell {
@Param shellState: AppShellState = new AppShellState();
@Param useWideLayout: boolean = false;
+ @Param compactAccountLogin: boolean = false;
@Param settingsPlacement: SettingsPlacement =
SettingsPlacementPolicy.compactBottom(SettingsSheetKind.Settings);
@Param connectPlacement: SettingsPlacement =
@@ -32,6 +35,18 @@ export struct AppShell {
build() {
Stack({ alignContent: Alignment.Start }) {
+ Column().width(0).height(0)
+ .bindContentCover(this.shellState.showSettings && this.compactAccountLogin,
+ this.CompactLoginCover(), {
+ backgroundColor: TRANSPARENT,
+ modalTransition: ModalTransition.NONE,
+ enableSafeArea: true,
+ onWillDismiss: (action: DismissContentCoverAction): void => {
+ this.shellState.showSettings = false;
+ action.dismiss();
+ }
+ })
+
Column() {
// The drawer is a compact affordance. Wide layouts put the same session
// list in the master pane instead, and this copy stayed mounted behind
@@ -102,7 +117,31 @@ export struct AppShell {
.onAreaChange((_oldArea: Area, newArea: Area) => {
this.shellWidth = this.areaLength(newArea.width);
})
- .bindSheet($$this.shellState.showSettings, this.SettingsSheetContent(), this.settingsSheetOptions())
+ .bindSheet(this.shellState.showSettings && !this.compactAccountLogin,
+ this.SettingsSheetContent(), this.settingsSheetOptions())
+
+ }
+
+ @Builder
+ private CompactLoginCover() {
+ Stack({ alignContent: Alignment.Bottom }) {
+ Column().width('100%').height('100%').backgroundColor(SCRIM)
+ .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
+ .onClick(() => { this.shellState.showSettings = false; })
+ Scroll() {
+ Column() { this.settings() }
+ .width('100%')
+ }
+ .width(Math.min(MobileDesignGeometry.loginSheetMaxWidth,
+ this.shellWidth - 2 * MobileDesignGeometry.loginSheetOuterMargin))
+ .scrollBar(BarState.Off)
+ .backgroundColor(PAGE_BG)
+ .borderRadius(MobileDesignGeometry.sheetTopRadius)
+ .clip(true)
+ .margin({ bottom: MobileDesignGeometry.loginSheetOuterMargin })
+ }
+ .width('100%')
+ .height('100%')
}
@Builder
@@ -111,7 +150,7 @@ export struct AppShell {
this.settings()
}
.width('100%')
- .height('100%')
+ .height(this.compactAccountLogin ? undefined : '100%')
.borderRadius(this.usesWideSheetChrome(this.settingsPlacement) ? WIDE_SHEET_RADIUS : 0)
.clip(this.usesWideSheetChrome(this.settingsPlacement))
.shadow({
@@ -148,7 +187,12 @@ export struct AppShell {
}
private settingsSheetOptions(): SheetOptions {
- return AdaptiveSheetOptions.fromPlacement(this.settingsPlacement);
+ const options = AdaptiveSheetOptions.fromPlacement(this.settingsPlacement);
+ options.onWillDismiss = (action: DismissSheetAction): void => {
+ this.shellState.showSettings = false;
+ action.dismiss();
+ };
+ return options;
}
private connectSheetOptions(): SheetOptions {
diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/OpenBitFunAccountLoginPage.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/OpenBitFunAccountLoginPage.ets
index c05fb1baea..b93a0dcaf1 100644
--- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/OpenBitFunAccountLoginPage.ets
+++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/OpenBitFunAccountLoginPage.ets
@@ -1,4 +1,4 @@
-import { MobileDesignTypography } from '../../generated/MobileDesignTokens';
+import { MobileDesignGeometry, MobileDesignTypography } from '../../generated/MobileDesignTokens';
import { RemoteI18n } from '../../i18n/RemoteI18n';
import { DEFAULT_CLOUD_RELAY_URL } from '../../services/CloudAccountClient';
import {
@@ -35,7 +35,7 @@ export struct OpenBitFunAccountLoginPage {
build() {
Column({ space: 0 }) {
- SheetCloseHeader({ onClose: this.onClose })
+ SheetCloseHeader({ onClose: this.onClose, uniformGlyph: true })
Scroll() {
Column() {
@@ -46,18 +46,18 @@ export struct OpenBitFunAccountLoginPage {
left: SHEET_HORIZONTAL_PADDING,
right: SHEET_HORIZONTAL_PADDING,
top: 0,
- bottom: SHEET_CONTENT_BOTTOM_PADDING
+ bottom: 0
})
.alignItems(HorizontalAlign.Center)
}
.width('100%')
- .layoutWeight(1)
.scrollBar(BarState.Off)
SheetActionFooter({
label: this.isBusy ? RemoteI18n.t('remote.settings.accountSigningIn') :
RemoteI18n.t('remote.settings.accountSignIn'),
primary: true,
+ elevated: false,
isEnabled: this.canSubmit(),
onAction: (): void => {
void this.submit();
@@ -65,7 +65,7 @@ export struct OpenBitFunAccountLoginPage {
})
}
.width('100%')
- .height('100%')
+
}
@Builder
@@ -85,7 +85,7 @@ export struct OpenBitFunAccountLoginPage {
.fontColor(MUTED)
.width('100%')
.textAlign(TextAlign.Center)
- .margin({ top: 8, bottom: 24 })
+ .margin({ top: 8 })
if (this.errorText.length > 0) {
@@ -98,7 +98,7 @@ export struct OpenBitFunAccountLoginPage {
}
}
.width('100%')
- .constraintSize({ maxWidth: SHEET_CONTENT_MAX_WIDTH })
+ .constraintSize({ maxWidth: SHEET_CONTENT_MAX_WIDTH, minHeight: MobileDesignGeometry.loginSheetBodyMinHeight })
.alignItems(HorizontalAlign.Center)
}
diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteControlSettingsSheet.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteControlSettingsSheet.ets
index 485dd50662..e291ecdefe 100644
--- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteControlSettingsSheet.ets
+++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteControlSettingsSheet.ets
@@ -68,7 +68,7 @@ export struct RemoteControlSettingsSheet {
}
}
.width('100%')
- .height('100%')
+ .height(this.currentPage() === 'login' ? undefined : '100%')
.backgroundColor(PAGE_BG)
.borderRadius({ topLeft: SHEET_TOP_RADIUS, topRight: SHEET_TOP_RADIUS })
}
diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SheetActionFooter.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SheetActionFooter.ets
index f08bcd0313..2d02724a7f 100644
--- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SheetActionFooter.ets
+++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SheetActionFooter.ets
@@ -13,6 +13,7 @@ export struct SheetActionFooter {
@Param label: string = '';
@Param primary: boolean = false;
@Param isEnabled: boolean = true;
+ @Param elevated: boolean = true;
@Event onAction: () => void = () => {};
build() {
@@ -23,6 +24,7 @@ export struct SheetActionFooter {
style: this.primary ? AppActionStyle.Primary : AppActionStyle.Secondary,
actionHeight: SHEET_ACTION_HEIGHT,
isEnabled: this.isEnabled,
+ elevated: this.elevated,
onAction: this.onAction
})
.layoutWeight(1)
diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SheetCloseButton.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SheetCloseButton.ets
index 55336f6e5b..ef2e9d6d02 100644
--- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SheetCloseButton.ets
+++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SheetCloseButton.ets
@@ -5,16 +5,22 @@ import { INK, TRANSPARENT } from './Theme';
/** Quiet top-right close affordance with a full-size touch target. */
@ComponentV2
export struct SheetCloseButton {
+ @Param uniformGlyph: boolean = false;
@Param touchSize: number = MobileDesignGeometry.controlTouchSize;
@Param isEnabled: boolean = true;
@Event onClose: () => void = () => {};
build() {
Button() {
+ if (this.uniformGlyph) {
+ Path().commands(this.closeGlyphPath())
+ .width(18).height(18).fill(TRANSPARENT).stroke(INK).strokeWidth(1.5).strokeLineCap(LineCapStyle.Round)
+ } else {
SymbolGlyph($r('sys.symbol.xmark'))
.fontSize(18)
.fontWeight(FontWeight.Medium)
.fontColor([INK])
+ }
}
.width(this.touchSize)
.height(this.touchSize)
@@ -29,4 +35,10 @@ export struct SheetCloseButton {
this.onClose();
})
}
+ private closeGlyphPath(): string {
+ const a = this.getUIContext().vp2px(2);
+ const b = this.getUIContext().vp2px(16);
+ return `M${a} ${a} L${b} ${b} M${b} ${a} L${a} ${b}`;
+ }
+
}
diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SheetCloseHeader.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SheetCloseHeader.ets
index 3cb6ed8191..5803dbb5b3 100644
--- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SheetCloseHeader.ets
+++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SheetCloseHeader.ets
@@ -6,6 +6,7 @@ import { INK, LINE } from './Theme';
/** Standard top-right dismissal header for modal and replace-in-place Sheet pages. */
@ComponentV2
export struct SheetCloseHeader {
+ @Param uniformGlyph: boolean = false;
@Param title: string = '';
@Param showDivider: boolean = false;
@Param isEnabled: boolean = true;
@@ -25,6 +26,7 @@ export struct SheetCloseHeader {
Blank()
SheetCloseButton({
isEnabled: this.isEnabled,
+ uniformGlyph: this.uniformGlyph,
onClose: this.onClose
})
}
diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Account/AccountSettingsView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Account/AccountSettingsView.swift
index d8f5389bad..9f56c6e1b0 100644
--- a/src/apps/mobile/ios/OpenBitFun/Features/Account/AccountSettingsView.swift
+++ b/src/apps/mobile/ios/OpenBitFun/Features/Account/AccountSettingsView.swift
@@ -4,6 +4,9 @@ import SwiftUI
struct AccountSettingsView: View {
@ObservedObject var model: MobileAppModel
var onClose: (() -> Void)? = nil
+ @ScaledMetric(relativeTo: .title2) private var loginTitleSize = MobileDesignTypography.displayMedium.size
+ @ScaledMetric(relativeTo: .body) private var loginBodySize = MobileDesignTypography.bodyMedium.size
+ @ScaledMetric(relativeTo: .caption) private var loginErrorSize = MobileDesignTypography.bodySmall.size
var body: some View {
Group {
if model.accountFailureStage == "DEVICE_LIST", model.accountFailureCanRetry {
@@ -14,68 +17,53 @@ struct AccountSettingsView: View {
profilePage
}
}
- .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .frame(maxWidth: .infinity, maxHeight: model.accountUser == nil && model.accountFailureStage != "DEVICE_LIST" ? nil : .infinity)
.background(OpenBitFunTheme.page)
}
private var loginPage: some View {
VStack(spacing: 0) {
- OpenBitFunModalHeader(title: "", onClose: close)
- .padding(.horizontal, MobileDesignGeometry.sheetHorizontalPadding)
+ ConnectionSheetHeader(onClose: close, uniformGlyph: true)
- ScrollView(showsIndicators: false) {
- VStack(spacing: 0) {
- Text(model.localized("使用 GitHub 登录"))
- .font(MobileDesignTypography.headlineMedium.font)
- .foregroundStyle(OpenBitFunTheme.ink)
- .multilineTextAlignment(.center)
- .frame(maxWidth: .infinity)
- Text(model.localized("登录后可查看并连接账号下的桌面设备。"))
- .font(MobileDesignTypography.bodyMedium.font)
- .foregroundStyle(OpenBitFunTheme.muted)
- .lineSpacing(MobileDesignTypography.bodyMedium.lineSpacing)
- .multilineTextAlignment(.center)
- .frame(maxWidth: .infinity)
- .padding(.top, 8)
- .padding(.bottom, 12)
+ VStack(spacing: 0) {
+ Text(model.localized("使用 GitHub 登录"))
+ .font(.system(size: loginTitleSize, weight: .bold))
+ .padding(.vertical, MobileDesignTypography.displayMedium.lineSpacing / 2)
+ .foregroundStyle(OpenBitFunTheme.ink)
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: .infinity)
+ Text(model.localized("使用 GitHub 登录并连接自己的电脑。\n任务和模型配置保留在被控电脑上。"))
+ .font(.system(size: loginBodySize))
+ .padding(.vertical, MobileDesignTypography.bodyMedium.lineSpacing / 2)
+ .foregroundStyle(OpenBitFunTheme.muted)
+ .lineSpacing(MobileDesignTypography.bodyMedium.lineSpacing)
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: .infinity)
+ .padding(.top, 8)
- if let error = model.coreErrorMessage, !error.isEmpty {
- Text(error)
- .font(MobileDesignTypography.bodySmall.font)
- .foregroundStyle(OpenBitFunTheme.statusDanger)
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(.horizontal, 4)
- .padding(.top, 12)
- }
+ if let error = model.coreErrorMessage, !error.isEmpty {
+ Text(error)
+ .font(.system(size: loginErrorSize))
+ .padding(.vertical, MobileDesignTypography.bodySmall.lineSpacing / 2)
+ .foregroundStyle(OpenBitFunTheme.statusDanger)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 4)
+ .padding(.top, 12)
}
- .frame(maxWidth: 520)
- .padding(.horizontal, MobileDesignGeometry.sheetHorizontalPadding)
- .padding(.bottom, 12)
- .frame(maxWidth: .infinity)
}
+ .frame(maxWidth: 520)
+ .padding(.horizontal, MobileDesignGeometry.sheetHorizontalPadding)
+ .frame(maxWidth: .infinity)
+ .frame(minHeight: MobileDesignGeometry.loginSheetBodyMinHeight, alignment: .top)
- Button {
- model.loginAccount()
- } label: {
- HStack(spacing: 8) {
- if model.accountBusy { ProgressView().tint(OpenBitFunTheme.contentOnAction) }
- Text(model.localized(model.accountAuthorizationURL != nil
- ? "打开 GitHub 授权"
- : model.accountBusy ? "正在登录" : "通过 GitHub 登录"))
- }
- .font(.system(size: 17, weight: .bold))
- .foregroundStyle(OpenBitFunTheme.contentOnAction)
- .frame(maxWidth: 520, minHeight: MobileDesignGeometry.sheetActionHeight)
- .background(canLogin ? OpenBitFunTheme.accent : OpenBitFunTheme.muted.opacity(0.35))
- .clipShape(Capsule())
- }
- .buttonStyle(.plain)
- .disabled(!canLogin)
+ ConnectionSheetFooter(
+ label: model.localized(model.accountAuthorizationURL != nil ? "打开 GitHub 授权"
+ : model.accountBusy ? "正在登录" : "使用 GitHub 登录"),
+ elevated: false, primary: true, enabled: canLogin, onAction: model.loginAccount
+ )
.accessibilityIdentifier("account.login")
- .padding(.horizontal, MobileDesignGeometry.sheetHorizontalPadding)
- .padding(.top, 10)
- .padding(.bottom, 24)
}
+ .fixedSize(horizontal: false, vertical: true)
}
private var deviceListRetryPage: some View {
diff --git a/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/AdaptiveModalComponents.swift b/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/AdaptiveModalComponents.swift
index 9839782238..079306ba5b 100644
--- a/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/AdaptiveModalComponents.swift
+++ b/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/AdaptiveModalComponents.swift
@@ -95,6 +95,7 @@ struct SignedOutConnectionActions: View {
let onScan: () -> Void
let onOpenAccount: () -> Void
var showScan = true
+ var primaryScan = false
var enabled = true
var buttonHeight: CGFloat = 48
var spacing: CGFloat = 10
@@ -106,9 +107,9 @@ struct SignedOutConnectionActions: View {
Button(action: onScan) {
Text(scanTitle)
.font(.system(size: fontSize, weight: .bold))
- .foregroundStyle(OpenBitFunTheme.ink)
+ .foregroundStyle(primaryScan ? OpenBitFunTheme.contentOnAction : OpenBitFunTheme.ink)
.frame(maxWidth: .infinity, minHeight: buttonHeight)
- .background(OpenBitFunTheme.card)
+ .background(primaryScan ? MobileDesignColors.primaryAction : OpenBitFunTheme.card)
.overlay(Capsule().stroke(OpenBitFunTheme.line, lineWidth: 1))
.clipShape(Capsule())
}
@@ -118,9 +119,10 @@ struct SignedOutConnectionActions: View {
Button(action: onOpenAccount) {
Text(accountTitle)
.font(.system(size: fontSize, weight: .bold))
- .foregroundStyle(OpenBitFunTheme.contentOnAction)
+ .foregroundStyle(primaryScan ? OpenBitFunTheme.ink : OpenBitFunTheme.contentOnAction)
.frame(maxWidth: .infinity, minHeight: buttonHeight)
- .background(OpenBitFunTheme.accent)
+ .background(primaryScan ? OpenBitFunTheme.card : MobileDesignColors.primaryAction)
+ .overlay(Capsule().stroke(primaryScan ? OpenBitFunTheme.line : MobileDesignColors.primaryAction, lineWidth: 1))
.clipShape(Capsule())
}
.buttonStyle(.plain)
@@ -177,6 +179,7 @@ extension View {
isPresented: Binding,
placement: SettingsPlacement,
onDismiss: (() -> Void)? = nil,
+ fitContent: Bool = false,
@ViewBuilder content: @escaping () -> ModalContent
) -> some View {
modifier(
@@ -184,6 +187,7 @@ extension View {
isPresented: isPresented,
placement: placement,
onDismiss: onDismiss,
+ fitContent: fitContent,
modalContent: content
)
)
@@ -198,17 +202,24 @@ private struct OpenBitFunAdaptiveModalModifier: ViewModifier
@Binding var isPresented: Bool
let placement: SettingsPlacement
let onDismiss: (() -> Void)?
+ let fitContent: Bool
+ @State private var contentHeight: CGFloat = 280
@ViewBuilder let modalContent: () -> ModalContent
- private var isSide: Bool { placement.mode == .side }
+ private var supportsFittedCover: Bool {
+ if #available(iOS 16.4, *) { return true }
+ return false
+ }
+
+ private var isSide: Bool { !fitContent && placement.mode == .side }
private var compactDetent: PresentationDetent {
- placement.height > 0 ? .height(CGFloat(placement.height)) : .large
+ fitContent ? .height(contentHeight) : placement.height > 0 ? .height(CGFloat(placement.height)) : .large
}
private var compactPresented: Binding {
Binding(
- get: { isPresented && !isSide },
+ get: { isPresented && !isSide && !(fitContent && supportsFittedCover) },
set: { if !$0 { isPresented = false } }
)
}
@@ -220,19 +231,68 @@ private struct OpenBitFunAdaptiveModalModifier: ViewModifier
)
}
+ private var fittedPresented: Binding {
+ Binding(get: { isPresented && fitContent && supportsFittedCover }, set: { if !$0 { isPresented = false } })
+ }
+
func body(content base: Content) -> some View {
base
.sheet(isPresented: compactPresented, onDismiss: onDismiss) {
compactSheet
}
+ .fullScreenCover(isPresented: fittedPresented, onDismiss: onDismiss) {
+ fittedCover
+ }
.fullScreenCover(isPresented: sidePresented, onDismiss: onDismiss) {
sideCover
}
}
+ @ViewBuilder
+ private var fittedCover: some View {
+ if #available(iOS 16.4, *) {
+ fittedPanel.presentationBackground(OpenBitFunTheme.transparent)
+ } else {
+ fittedPanel
+ }
+ }
+
+ private var fittedPanel: some View {
+ GeometryReader { geometry in
+ ZStack(alignment: .bottom) {
+ OpenBitFunTheme.scrim.ignoresSafeArea()
+ .contentShape(Rectangle())
+ .onTapGesture { isPresented = false }
+ ScrollView(showsIndicators: false) {
+ modalContent()
+ .background {
+ GeometryReader { contentGeometry in
+ Color.clear.preference(key: ConnectionSheetHeightKey.self, value: contentGeometry.size.height)
+ }
+ }
+ }
+ .frame(width: min(MobileDesignGeometry.loginSheetMaxWidth,
+ geometry.size.width - 2 * MobileDesignGeometry.loginSheetOuterMargin),
+ height: min(contentHeight, max(0, geometry.size.height - 2 * MobileDesignGeometry.loginSheetOuterMargin)))
+ .background(OpenBitFunTheme.page)
+ .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.sheetTopRadius, style: .circular))
+ .padding(.bottom, MobileDesignGeometry.loginSheetOuterMargin)
+ .onPreferenceChange(ConnectionSheetHeightKey.self) { height in
+ if height > 0 { contentHeight = height }
+ }
+ .accessibilityAddTraits(.isModal)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ }
+
@ViewBuilder
private var compactSheet: some View {
- let surface = modalContent()
+ let surface = sizedContent
+ .background(OpenBitFunTheme.page)
+ .onPreferenceChange(ConnectionSheetHeightKey.self) { height in
+ if fitContent && height > 0 { contentHeight = max(280, height) }
+ }
.presentationDetents([compactDetent])
.presentationDragIndicator(.hidden)
if #available(iOS 16.4, *) {
@@ -242,6 +302,22 @@ private struct OpenBitFunAdaptiveModalModifier: ViewModifier
}
}
+ @ViewBuilder
+ private var sizedContent: some View {
+ if fitContent {
+ ScrollView(showsIndicators: false) {
+ modalContent()
+ .background {
+ GeometryReader { geometry in
+ Color.clear.preference(key: ConnectionSheetHeightKey.self, value: geometry.size.height)
+ }
+ }
+ }
+ } else {
+ modalContent()
+ }
+ }
+
@ViewBuilder
private var sideCover: some View {
let cover = ZStack(alignment: .trailing) {
@@ -279,3 +355,69 @@ private struct OpenBitFunAdaptiveModalModifier: ViewModifier
}
}
}
+
+/// Connection and login pages mirror Harmony's SheetCloseHeader and SheetActionFooter.
+struct ConnectionSheetHeader: View {
+ let onClose: () -> Void
+ var uniformGlyph = false
+ var body: some View {
+ HStack {
+ Spacer()
+ Button(action: onClose) {
+ Group {
+ if uniformGlyph {
+ Path { path in
+ path.move(to: CGPoint(x: 2, y: 2)); path.addLine(to: CGPoint(x: 16, y: 16))
+ path.move(to: CGPoint(x: 16, y: 2)); path.addLine(to: CGPoint(x: 2, y: 16))
+ }
+ .stroke(OpenBitFunTheme.ink, style: StrokeStyle(lineWidth: 1.5, lineCap: .round))
+ .frame(width: 18, height: 18)
+ } else {
+ Image(systemName: "xmark")
+ .font(.system(size: 18, weight: .medium))
+ .foregroundStyle(OpenBitFunTheme.ink)
+ }
+ }
+ .frame(width: MobileDesignGeometry.controlTouchSize, height: MobileDesignGeometry.controlTouchSize)
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(MobileLocalization.text("关闭"))
+ }
+ .padding(.trailing, 8)
+ .frame(height: MobileDesignGeometry.sheetHeaderHeight)
+ }
+}
+
+struct ConnectionSheetFooter: View {
+ let label: String
+ var elevated = true
+ var primary = false
+ var enabled = true
+ let onAction: () -> Void
+ var body: some View {
+ Button(action: onAction) {
+ Text(label)
+ .font(MobileDesignTypography.labelLarge.font)
+ .foregroundStyle(primary ? OpenBitFunTheme.contentOnAction : OpenBitFunTheme.ink)
+ .frame(maxWidth: .infinity, minHeight: MobileDesignGeometry.sheetActionHeight)
+ .background(primary ? MobileDesignColors.primaryAction : OpenBitFunTheme.card)
+ .clipShape(Capsule())
+ .overlay(Capsule().stroke(primary ? OpenBitFunTheme.transparent : OpenBitFunTheme.line, lineWidth: 1))
+ .shadow(color: primary ? MobileDesignColors.shadowSubtle : MobileDesignColors.shadowFaint,
+ radius: elevated ? 14 : 0, y: elevated ? 5 : 0)
+ }
+ .buttonStyle(.plain)
+ .disabled(!enabled)
+ .opacity(enabled ? 1 : 0.32)
+ .frame(maxWidth: 520)
+ .padding(.horizontal, MobileDesignGeometry.sheetHorizontalPadding)
+ .frame(maxWidth: .infinity)
+ .padding(.top, 10)
+ .padding(.bottom, 24)
+ }
+}
+
+private struct ConnectionSheetHeightKey: PreferenceKey {
+ static var defaultValue: CGFloat = 0
+ static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = max(value, nextValue()) }
+}
diff --git a/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/GeneratedMobileDesignTokens.swift b/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/GeneratedMobileDesignTokens.swift
index 92fd5ba330..33590ccd7b 100644
--- a/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/GeneratedMobileDesignTokens.swift
+++ b/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/GeneratedMobileDesignTokens.swift
@@ -128,6 +128,9 @@ enum MobileDesignGeometry {
static let composerModelSelectorRowRadius: CGFloat = 9
static let composerModelSelectorRowGap: CGFloat = 6
static let sheetTopRadius: CGFloat = 28
+ static let loginSheetMaxWidth: CGFloat = 560
+ static let loginSheetOuterMargin: CGFloat = 8
+ static let loginSheetBodyMinHeight: CGFloat = 142
static let sheetSideRadius: CGFloat = 28
static let sheetHorizontalPadding: CGFloat = 20
static let sheetHeaderHeight: CGFloat = 56
diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Pairing/PairingSheet.swift b/src/apps/mobile/ios/OpenBitFun/Features/Pairing/PairingSheet.swift
index 509e4e183c..a79f5f7e90 100644
--- a/src/apps/mobile/ios/OpenBitFun/Features/Pairing/PairingSheet.swift
+++ b/src/apps/mobile/ios/OpenBitFun/Features/Pairing/PairingSheet.swift
@@ -243,107 +243,107 @@ struct PairingSheet: View {
}
private var introPage: some View {
- VStack(spacing: 0) {
- hero(height: 250)
- VStack(spacing: 15) {
- Image(systemName: "desktopcomputer")
- .font(.system(size: 54, weight: .medium))
- .foregroundStyle(OpenBitFunTheme.ink)
- .frame(width: 88, height: 88)
- .background(OpenBitFunTheme.card)
- .clipShape(RoundedRectangle(cornerRadius: 28))
- .shadow(color: OpenBitFunTheme.line, radius: 18, y: 7)
- Text(model.localized("选择连接方式"))
- .font(.system(size: 24, weight: .bold))
- .foregroundStyle(OpenBitFunTheme.ink)
+ ScrollView(showsIndicators: false) {
+ VStack(spacing: 0) {
+ hero(height: 250)
+ VStack(spacing: 18) {
+ ZStack(alignment: .topLeading) {
+ RoundedRectangle(cornerRadius: 8)
+ .strokeBorder(OpenBitFunTheme.ink, lineWidth: 5)
+ .frame(width: 58, height: 39).offset(x: 5)
+ Rectangle().strokeBorder(OpenBitFunTheme.ink, lineWidth: 5)
+ .frame(width: 26, height: 13).offset(x: 21, y: 38)
+ }
+ .frame(width: 68, height: 55)
+ Text(model.localized("连接电脑"))
+ .font(MobileDesignTypography.displayLarge.font)
+ .foregroundStyle(OpenBitFunTheme.ink)
+ SignedOutConnectionActions(
+ scanTitle: model.localized("扫码连接电脑"),
+ accountTitle: model.localized("使用 GitHub 登录"),
+ onScan: { scanError = nil; step = .scan },
+ onOpenAccount: model.openAccountFromPairing,
+ primaryScan: true,
+ enabled: !model.pairingBusy,
+ buttonHeight: 58,
+ spacing: 18,
+ fontSize: 16
+ )
+ .frame(maxWidth: 520)
+ .padding(.horizontal, 36)
+ }
+ .offset(y: -24)
}
- .padding(.horizontal, 28)
- .offset(y: -10)
- Spacer(minLength: 12)
- SignedOutConnectionActions(
- scanTitle: model.localized("扫码连接"),
- accountTitle: model.localized("使用 GitHub 登录"),
- onScan: {
- scanError = nil
- step = .scan
- },
- onOpenAccount: model.openAccountFromPairing,
- enabled: !model.pairingBusy,
- buttonHeight: 58,
- spacing: 12,
- fontSize: 20
- )
- .padding(.horizontal, 44)
- .padding(.bottom, 34)
+ .padding(.bottom, 28)
}
}
private var scanPage: some View {
VStack(spacing: 0) {
- HStack {
- Spacer()
- Button { dismiss() } label: {
- Image(systemName: "xmark")
- .font(.system(size: 17, weight: .medium))
- .foregroundStyle(OpenBitFunTheme.ink)
- .frame(width: 44, height: 44)
- }
- .buttonStyle(.plain)
- }
- .padding(.horizontal, 18)
- .padding(.top, 8)
+ ConnectionSheetHeader(onClose: { dismiss() })
- ScrollView(showsIndicators: false) {
- VStack(spacing: 0) {
- Text(model.localized("扫描桌面端二维码"))
- .font(.system(size: 24, weight: .bold))
- .foregroundStyle(OpenBitFunTheme.ink)
- .multilineTextAlignment(.center)
- Text(model.localized("在 OpenBitFun 桌面端点击「连接移动端」\n扫描二维码完成连接"))
- .font(MobileDesignTypography.bodyLarge.font)
- .foregroundStyle(OpenBitFunTheme.muted)
- .lineSpacing(MobileDesignTypography.bodyLarge.lineSpacing)
- .multilineTextAlignment(.center)
- .padding(.top, 8)
- .padding(.bottom, 24)
+ GeometryReader { geometry in
+ ScrollView(showsIndicators: false) {
+ VStack(spacing: 0) {
+ Text(model.localized("扫描桌面端二维码"))
+ .font(MobileDesignTypography.displayMedium.font)
+ .padding(.vertical, MobileDesignTypography.displayMedium.lineSpacing / 2)
+ .foregroundStyle(OpenBitFunTheme.ink)
+ .multilineTextAlignment(.center)
+ Text(model.localized("在 OpenBitFun 桌面端点击「连接移动端」\n扫描二维码完成连接"))
+ .font(MobileDesignTypography.bodyMedium.font)
+ .padding(.vertical, MobileDesignTypography.bodyMedium.lineSpacing / 2)
+ .foregroundStyle(OpenBitFunTheme.muted)
+ .lineSpacing(MobileDesignTypography.bodyMedium.lineSpacing)
+ .multilineTextAlignment(.center)
+ .padding(.top, 8)
+ .padding(.bottom, 24)
- inlineScanner
+ inlineScanner
+ HStack(spacing: 8) {
+ Circle().fill(OpenBitFunTheme.muted).frame(width: 8, height: 8)
+ Text(model.localized("扫描二维码或粘贴远程连接链接后会显示桌面端连接状态。"))
+ .font(MobileDesignTypography.bodySmall.font)
+ .padding(.vertical, MobileDesignTypography.bodySmall.lineSpacing / 2)
+ .foregroundStyle(OpenBitFunTheme.muted)
+ .lineLimit(1)
+ }
+ .padding(.horizontal, 12)
+ .frame(maxWidth: .infinity, minHeight: 30)
+ .background(OpenBitFunTheme.soft)
+ .clipShape(Capsule())
+ .padding(.top, 18)
- if let error = scanError ?? model.pairingError {
- Text(error)
- .font(MobileDesignTypography.bodySmall.font)
- .foregroundStyle(scanError == nil
- ? OpenBitFunTheme.statusDanger
- : OpenBitFunTheme.muted)
- .multilineTextAlignment(.center)
- .padding(.horizontal, 14)
- .padding(.vertical, 12)
- .frame(maxWidth: .infinity)
- .background(OpenBitFunTheme.soft)
- .clipShape(RoundedRectangle(cornerRadius: 16))
- .padding(.top, 16)
+ if let error = scanError ?? model.pairingError {
+ Text(error)
+ .font(MobileDesignTypography.bodySmall.font)
+ .padding(.vertical, MobileDesignTypography.bodySmall.lineSpacing / 2)
+ .foregroundStyle(scanError == nil
+ ? OpenBitFunTheme.statusDanger
+ : OpenBitFunTheme.muted)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 14)
+ .padding(.vertical, 12)
+ .frame(maxWidth: .infinity)
+ .background(OpenBitFunTheme.soft)
+ .clipShape(RoundedRectangle(cornerRadius: 16))
+ .padding(.top, 14)
+ }
}
+ .frame(maxWidth: 520)
+ .padding(.horizontal, MobileDesignGeometry.sheetHorizontalPadding)
+ .frame(maxWidth: .infinity, minHeight: geometry.size.height, alignment: .center)
}
- .frame(maxWidth: 520)
- .padding(.horizontal, 28)
- .padding(.bottom, 20)
- .frame(maxWidth: .infinity)
}
+ .padding(.bottom, 18)
- Button { manualOpen = true; focused = true } label: {
- Text(model.localized("改为手动配对"))
- .font(.system(size: 17, weight: .semibold))
- .foregroundStyle(OpenBitFunTheme.ink)
- .frame(maxWidth: .infinity, minHeight: 58)
- .background(OpenBitFunTheme.card)
- .overlay(Capsule().stroke(OpenBitFunTheme.line, lineWidth: 1.5))
- .clipShape(Capsule())
+ ConnectionSheetFooter(label: model.localized("改为手动配对")) {
+ manualOpen = true
+ focused = true
}
- .buttonStyle(.plain)
- .padding(.horizontal, 44)
- .padding(.bottom, 34)
}
.background(OpenBitFunTheme.page)
+ .ignoresSafeArea(.container, edges: .bottom)
}
private var inlineScanner: some View {
@@ -366,17 +366,22 @@ struct PairingSheet: View {
)
.frame(width: 248, height: 248)
- ForEach(0..<4, id: \.self) { index in
- PairingScanCorner()
- .stroke(MobileDesignColors.connectScanAccent, style: StrokeStyle(lineWidth: 4, lineCap: .round))
- .frame(width: 52, height: 52)
- .rotationEffect(.degrees(Double(index) * 90))
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: scanCornerAlignment(index))
- .padding(20)
+ MobileDesignColors.shadowMedium
+ Canvas { context, size in
+ for right in [false, true] {
+ for bottom in [false, true] {
+ let x = right ? size.width - 20 - 56 : 20
+ let y = bottom ? size.height - 20 - 56 : 20
+ let horizontal = CGRect(x: x + (right ? 20 : 0), y: y + (bottom ? 52 : 0), width: 36, height: 4)
+ let vertical = CGRect(x: x + (right ? 52 : 0), y: y + (bottom ? 20 : 0), width: 4, height: 36)
+ context.fill(Path(roundedRect: horizontal, cornerRadius: 2), with: .color(MobileDesignColors.connectScanAccent))
+ context.fill(Path(roundedRect: vertical, cornerRadius: 2), with: .color(MobileDesignColors.connectScanAccent))
+ }
+ }
}
}
.frame(width: 248, height: 248)
- .background(OpenBitFunTheme.mediaBackground)
+ .background(OpenBitFunTheme.page)
.clipShape(RoundedRectangle(cornerRadius: 28))
.overlay(RoundedRectangle(cornerRadius: 28).stroke(OpenBitFunTheme.line, lineWidth: 1))
}
@@ -387,22 +392,16 @@ struct PairingSheet: View {
model.submitPairing(url: code)
}
- private func scanCornerAlignment(_ index: Int) -> Alignment {
- switch index {
- case 0: .topLeading
- case 1: .topTrailing
- case 2: .bottomTrailing
- default: .bottomLeading
- }
- }
-
private func hero(height: CGFloat) -> some View {
ZStack(alignment: .topLeading) {
- LinearGradient(
- colors: [MobileDesignColors.connectHeroBg, MobileDesignColors.connectHeroSurface],
- startPoint: .topLeading,
- endPoint: .bottomTrailing
- )
+ RoundedRectangle(cornerRadius: 36).fill(MobileDesignColors.connectHeroBg)
+ .frame(height: 282)
+ RoundedRectangle(cornerRadius: 72).fill(MobileDesignColors.connectHeroSurface.opacity(0.7))
+ .frame(width: 260, height: 142).offset(x: 112, y: 26)
+ RoundedRectangle(cornerRadius: 68).fill(MobileDesignColors.connectHeroAccent.opacity(0.42))
+ .frame(width: 188, height: 134).offset(x: -42, y: 198)
+ RoundedRectangle(cornerRadius: 64).fill(MobileDesignColors.connectHeroSecondary.opacity(0.54))
+ .frame(width: 188, height: 126).offset(x: 258)
Button {
if step == .scan {
step = model.accountUser == nil ? .intro : .account
@@ -485,17 +484,3 @@ struct PairingSheet: View {
.buttonStyle(.plain)
}
}
-
-private struct PairingScanCorner: Shape {
- func path(in rect: CGRect) -> Path {
- var path = Path()
- path.move(to: CGPoint(x: 0, y: rect.height))
- path.addLine(to: CGPoint(x: 0, y: 12))
- path.addQuadCurve(
- to: CGPoint(x: 12, y: 0),
- control: CGPoint(x: 0, y: 0)
- )
- path.addLine(to: CGPoint(x: rect.width, y: 0))
- return path
- }
-}
diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Remote/RemoteHomeViews.swift b/src/apps/mobile/ios/OpenBitFun/Features/Remote/RemoteHomeViews.swift
index 9a1e053d33..43924fd297 100644
--- a/src/apps/mobile/ios/OpenBitFun/Features/Remote/RemoteHomeViews.swift
+++ b/src/apps/mobile/ios/OpenBitFun/Features/Remote/RemoteHomeViews.swift
@@ -1,5 +1,4 @@
import OpenBitFunMobileCore
-import OpenBitFunMobileCore
import SwiftUI
struct RemoteHomeView: View {
@@ -18,19 +17,19 @@ struct RemoteHomeView: View {
.overlay(RoundedRectangle(cornerRadius: 24).stroke(OpenBitFunTheme.line, lineWidth: 1))
.clipShape(RoundedRectangle(cornerRadius: 24))
Text(model.localized("连接桌面端"))
- .font(.system(size: 18, weight: .bold))
+ .font(MobileDesignTypography.headlineSmall.font)
.foregroundStyle(OpenBitFunTheme.ink)
Text(model.localized("扫描桌面端显示的二维码,开始远程处理任务。"))
- .font(.system(size: 13))
+ .font(MobileDesignTypography.bodySmall.font)
.foregroundStyle(OpenBitFunTheme.muted)
.multilineTextAlignment(.center)
.lineSpacing(7)
.padding(.horizontal, 20)
Button(model.localized("连接")) { model.connectRemote() }
- .font(.system(size: 15, weight: .medium))
+ .font(MobileDesignTypography.titleSmall.font)
.foregroundStyle(OpenBitFunTheme.contentOnAction)
.frame(width: 136, height: 44)
- .background(OpenBitFunTheme.accent)
+ .background(MobileDesignColors.primaryAction)
.clipShape(Capsule())
Spacer()
}
diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Shell/MobileShellView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Shell/MobileShellView.swift
index 76ae3791c2..e3b75d892c 100644
--- a/src/apps/mobile/ios/OpenBitFun/Features/Shell/MobileShellView.swift
+++ b/src/apps/mobile/ios/OpenBitFun/Features/Shell/MobileShellView.swift
@@ -4,7 +4,6 @@ import UniformTypeIdentifiers
struct MobileShellView: View {
@ObservedObject var model: MobileAppModel
- @Environment(\.dynamicTypeSize) private var dynamicTypeSize
@State private var wideSidebarCollapsed = false
@State private var sessionActionsOpen = false
@State private var sidebarActionSession: ChatSession?
@@ -136,14 +135,6 @@ struct MobileShellView: View {
input: adaptiveInput,
kind: .settings
)
- let compactAccountLogin = model.accountUser == nil && model.accountFailureStage != "DEVICE_LIST"
- && !dynamicTypeSize.isAccessibilitySize
- let accountPlacement = compactAccountLogin ? SettingsPlacement(
- mode: settingsPlacement.mode,
- width: settingsPlacement.width,
- height: Int32(min(CGFloat(model.coreErrorMessage == nil ? 280 : 380), max(240, CGFloat(height) - 40))),
- maxHeight: settingsPlacement.maxHeight
- ) : settingsPlacement
let connectPlacement = SettingsPlacementPolicy.shared.resolve(
input: adaptiveInput,
kind: .connect
@@ -278,7 +269,8 @@ struct MobileShellView: View {
}
.openBitFunAdaptiveModal(
isPresented: $model.accountSheetOpen,
- placement: accountPlacement
+ placement: settingsPlacement,
+ fitContent: model.accountUser == nil && model.accountFailureStage != "DEVICE_LIST"
) {
AccountSettingsView(model: model)
}
diff --git a/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings b/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings
index 142a424ee7..8278a03b5f 100644
--- a/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings
+++ b/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings
@@ -8020,6 +8020,22 @@
}
}
}
+ },
+ "使用 GitHub 登录并连接自己的电脑。\n任务和模型配置保留在被控电脑上。": {
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Sign in with GitHub to connect to your computers.\nTasks and model settings stay on the controlled computer."
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "使用 GitHub 登录并连接自己的电脑。\n任务和模型配置保留在被控电脑上。"
+ }
+ }
+ }
}
},
"version": "1.0"
diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/AccountUiState.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/AccountUiState.kt
index d84e06dddc..e353cc5d35 100644
--- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/AccountUiState.kt
+++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/AccountUiState.kt
@@ -65,12 +65,6 @@ public sealed interface AccountUiState {
refreshFailure: AccountFailureReason?,
) : this(userId, relayUrl, username, devices, selectedDeviceId, selectedDeviceName, refreshing, refreshFailure, null)
- public constructor(
- userId: String, username: String, devices: List,
- selectedDeviceId: String?, selectedDeviceName: String?,
- ) : this(userId, com.openbitfun.mobile.core.transport.DEFAULT_CLOUD_RELAY_URL, username, devices,
- selectedDeviceId, selectedDeviceName, false, null, null)
-
public constructor(
userId: String,
username: String,