git.iko.soy/opencode-android
on master help sign in
Fix AMOLED theme preview indistinguishable from Dark, measure connection banner height instead of hardcoded 44dp, make crash report text selectable, and add visible Loading label to session list spinner
3a20f35iko
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+23−4
@@ -88,6 +88,7 @@ import androidx.compose.runtime.DisposableEffect
88import androidx.compose.runtime.LaunchedEffect
88import androidx.compose.runtime.LaunchedEffect
89import androidx.compose.runtime.derivedStateOf
89import androidx.compose.runtime.derivedStateOf
90import androidx.compose.runtime.getValue
90import androidx.compose.runtime.getValue
91+import androidx.compose.runtime.mutableIntStateOf
91import androidx.compose.runtime.mutableStateOf
92import androidx.compose.runtime.mutableStateOf
92import androidx.compose.runtime.remember
93import androidx.compose.runtime.remember
93import androidx.compose.runtime.rememberCoroutineScope
94import androidx.compose.runtime.rememberCoroutineScope
@@ -108,7 +109,9 @@ import androidx.compose.ui.input.key.isShiftPressed
108import androidx.compose.ui.input.key.key
109import androidx.compose.ui.input.key.key
109import androidx.compose.ui.input.key.onPreviewKeyEvent
110import androidx.compose.ui.input.key.onPreviewKeyEvent
110import androidx.compose.ui.input.key.type
111import androidx.compose.ui.input.key.type
112+import androidx.compose.ui.layout.onSizeChanged
111import androidx.compose.ui.platform.LocalContext
113import androidx.compose.ui.platform.LocalContext
114+import androidx.compose.ui.platform.LocalDensity
112import androidx.compose.ui.platform.LocalHapticFeedback
115import androidx.compose.ui.platform.LocalHapticFeedback
113import androidx.compose.ui.platform.LocalView
116import androidx.compose.ui.platform.LocalView
114import androidx.compose.ui.platform.testTag
117import androidx.compose.ui.platform.testTag
@@ -894,9 +897,27 @@ fun ChatScreen(
894 }
897 }
895 }
898 }
896 } else {
899 } else {
900+ val bannerVisible = !isOnline || connectionState != EventStreamClient.ConnectionState.Connected
901+ // Measure the banner's actual height instead of hardcoding 44dp. At large
902+ // accessibility font scales the banner's labelMedium text + spinner + Retry
903+ // button exceed 44dp, so a fixed inset lets the first message hide behind it
904+ // (the same hazard FileViewScreen's overlay calls out). onSizeChanged only
905+ // fires while the banner is composed (visible), so gate the inset on
906+ // bannerVisible and fall back to 44dp until the first measurement arrives.
907+ var bannerHeightPx by remember { mutableIntStateOf(0) }
908+ val density = LocalDensity.current
909+ val topPad = if (bannerVisible) {
910+ if (bannerHeightPx > 0) {
911+ with(density) { bannerHeightPx.toDp() } + 4.dp
912+ } else {
913+ 44.dp
914+ }
915+ } else {
916+ 16.dp
917+ }
897 ConnectionBanner(
918 ConnectionBanner(
898 state = connectionState,
919 state = connectionState,
899− modifier = Modifier.align(Alignment.TopCenter),
920+ modifier = Modifier.align(Alignment.TopCenter).onSizeChanged { bannerHeightPx = it.height },
900 isOnline = isOnline,
921 isOnline = isOnline,
901 // On a hard failure, retry by forcing an SSE reconnect (which
922 // On a hard failure, retry by forcing an SSE reconnect (which
902 // re-seeds from REST). refreshMessages is the right recovery path
923 // re-seeds from REST). refreshMessages is the right recovery path
@@ -918,9 +939,7 @@ fun ChatScreen(
918 .widthIn(max = NetworkConfig.chatContentMaxWidthDp.dp),
939 .widthIn(max = NetworkConfig.chatContentMaxWidthDp.dp),
919 ) {
940 ) {
920 // Reserve top space when the banner is visible so it doesn't overlap the
941 // Reserve top space when the banner is visible so it doesn't overlap the
921− // first message / state content.
942+ // first message / state content. (topPad computed above.)
922− val bannerVisible = !isOnline || connectionState != EventStreamClient.ConnectionState.Connected
923− val topPad = if (bannerVisible) 44.dp else 16.dp
924 if (loading && messages.isEmpty()) {
943 if (loading && messages.isEmpty()) {
925 val loadingLabel = stringResource(R.string.loading)
944 val loadingLabel = stringResource(R.string.loading)
926 // Attach the a11y label directly to the spinner (a detached zero-size Box
945 // Attach the a11y label directly to the spinner (a detached zero-size Box
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+16−5
@@ -438,11 +438,22 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
438 when {
438 when {
439 state.loading -> {
439 state.loading -> {
440 val loadingLabel = stringResource(R.string.loading)
440 val loadingLabel = stringResource(R.string.loading)
441− CircularProgressIndicator(
441+ // Match sibling screens (MCP/Usage/Chat) which show a visible "Loading…"
442− Modifier
442+ // label beneath the spinner; a bare spinner is the weakest loading state
443+ // in the app and this is the primary screen.
444+ Column(
443− .align(Alignment.Center)
445+ modifier = Modifier.align(Alignment.Center),
446+ horizontalAlignment = Alignment.CenterHorizontally,
447+ ) {
448+ CircularProgressIndicator(
444− .semantics { contentDescription = loadingLabel },
449+ Modifier.semantics { contentDescription = loadingLabel },
445− )
450+ )
451+ Spacer(Modifier.size(12.dp))
452+ Text(
453+ loadingLabel,
454+ color = MaterialTheme.colorScheme.onSurfaceVariant,
455+ )
456+ }
446 }
457 }
447 state.sessions.isEmpty() && state.error != null -> Column(
458 state.sessions.isEmpty() && state.error != null -> Column(
448 modifier = Modifier.align(Alignment.Center).padding(24.dp),
459 modifier = Modifier.align(Alignment.Center).padding(24.dp),
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+10−5
@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.size
18import androidx.compose.foundation.lazy.LazyColumn
18import androidx.compose.foundation.lazy.LazyColumn
19import androidx.compose.foundation.lazy.items
19import androidx.compose.foundation.lazy.items
20import androidx.compose.foundation.rememberScrollState
20import androidx.compose.foundation.rememberScrollState
21+import androidx.compose.foundation.text.selection.SelectionContainer
21import androidx.compose.foundation.verticalScroll
22import androidx.compose.foundation.verticalScroll
22import androidx.compose.material.icons.Icons
23import androidx.compose.material.icons.Icons
23import androidx.compose.material.icons.filled.BugReport
24import androidx.compose.material.icons.filled.BugReport
@@ -372,11 +373,15 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
372 .heightIn(max = 400.dp)
373 .heightIn(max = 400.dp)
373 .verticalScroll(rememberScrollState()),
374 .verticalScroll(rememberScrollState()),
374 ) {
375 ) {
376+ // Wrap in SelectionContainer so a user can select/copy just the
377+ // relevant stack snippet instead of the whole-report Copy button.
378+ SelectionContainer {
375− Text(
379+ Text(
376− content,
380+ content,
377− style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
381+ style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
378− modifier = Modifier.fillMaxWidth(),
382+ modifier = Modifier.fillMaxWidth(),
379− )
383+ )
384+ }
380 }
385 }
381 loadFailed -> Text(
386 loadFailed -> Text(
382 stringResource(R.string.report_load_failed),
387 stringResource(R.string.report_load_failed),
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+3−1
@@ -87,6 +87,7 @@ import soy.iko.opencode.data.network.NetworkConfig
87import soy.iko.opencode.ui.canAuthenticateForAppLock
87import soy.iko.opencode.ui.canAuthenticateForAppLock
88import soy.iko.opencode.ui.theme.LightPaletteSwatches
88import soy.iko.opencode.ui.theme.LightPaletteSwatches
89import soy.iko.opencode.ui.theme.DarkPaletteSwatches
89import soy.iko.opencode.ui.theme.DarkPaletteSwatches
90+import soy.iko.opencode.ui.theme.AmoledPaletteSwatches
90import soy.iko.opencode.util.runCatchingCancellable
91import soy.iko.opencode.util.runCatchingCancellable
91import kotlin.math.roundToInt
92import kotlin.math.roundToInt
92
93
@@ -716,7 +717,7 @@ private fun ThemeRow(mode: ThemeMode, selected: Boolean, usingSystemColors: Bool
716 }
717 }
717 ThemeMode.LIGHT -> LightSwatches
718 ThemeMode.LIGHT -> LightSwatches
718 ThemeMode.DARK -> DarkSwatches
719 ThemeMode.DARK -> DarkSwatches
719− ThemeMode.AMOLED -> DarkSwatches
720+ ThemeMode.AMOLED -> AmoledSwatches
720 }
721 }
721 Row(
722 Row(
722 modifier = Modifier
723 modifier = Modifier
@@ -766,6 +767,7 @@ private fun ThemeRow(mode: ThemeMode, selected: Boolean, usingSystemColors: Bool
766// the character of each scheme at a glance without rendering a full sample surface.
767// the character of each scheme at a glance without rendering a full sample surface.
767private val DarkSwatches: List<Color> = DarkPaletteSwatches
768private val DarkSwatches: List<Color> = DarkPaletteSwatches
768private val LightSwatches: List<Color> = LightPaletteSwatches
769private val LightSwatches: List<Color> = LightPaletteSwatches
770+private val AmoledSwatches: List<Color> = AmoledPaletteSwatches
769
771
770// Composable-side read of the system dark setting for the "System" theme row preview.
772// Composable-side read of the system dark setting for the "System" theme row preview.
771// isSystemInDarkTheme() is a @Composable function, so it can't be called inside a plain
773// isSystemInDarkTheme() is a @Composable function, so it can't be called inside a plain
app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+9
@@ -155,6 +155,15 @@ val LightPaletteSwatches: List<Color> = listOf(
155 LightColors.tertiary,
155 LightColors.tertiary,
156 LightColors.background,
156 LightColors.background,
157)
157)
158+// AMOLED shares the Dark accent colors but uses a pure-black background, so its preview
159+// must differ from Dark's (#1A1B26) dot — otherwise the two theme rows are visually
160+// indistinguishable in the picker and the user can't tell them apart before selecting.
161+val AmoledPaletteSwatches: List<Color> = listOf(
162+ AmoledColors.primary,
163+ AmoledColors.secondary,
164+ AmoledColors.tertiary,
165+ AmoledColors.background,
166+)
158
167
159private tailrec fun Context.findActivity(): Activity? = when (this) {
168private tailrec fun Context.findActivity(): Activity? = when (this) {
160 is Activity -> this
169 is Activity -> this