* fix(chat): surface all disconnected states in reconnect banner (issue #491) The connection-status banner only appeared for RECONNECTING/DISCONNECTED, so NO_NETWORK and AUTH_EXPIRED left the user staring at a silent chat with no feedback and no way to recover — the core of the 'reconnecting is not user-friendly' complaint. ChatTopBanner now shows for every non-connected, non-connecting state: - RECONNECTING: spinner + 'Reconnecting to Hermes…' (auto, no button) - DISCONNECTED: 'Disconnected from Hermes' + Reconnect - NO_NETWORK: 'No network connection' + Reconnect (force a retry) - AUTH_EXPIRED: 'Session expired' + 'Sign in again' -> AuthLoginScreen The auto-reconnect engine (backoff, network-return trigger, in-flight message queue in HermesWsClient) is untouched — this only makes the stuck states visible and actionable. * fix(chat): use LocalHermesStatusColors for reconnect banner (review fix) Per AGENTS.md, semantic status colors must come from LocalHermesStatusColors, never MaterialTheme.colorScheme. The reconnect banner used errorContainer/onErrorContainer directly, which is both a semantic-color violation and inconsistent with the rest of the app (Navigation.kt maps DISCONNECTED/NO_NETWORK/AUTH_EXPIRED -> .error; TelegramOnboardingDialog uses .errorContainer for error surfaces). Switch Surface bg to errorContainer and foreground (spinner, text, button content) to error via LocalHermesStatusColors.current.
This commit is contained in:
@@ -109,12 +109,14 @@ import androidx.core.content.FileProvider
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
|
import com.m57.hermescontrol.AuthLoginScreen
|
||||||
import com.m57.hermescontrol.NavigationController
|
import com.m57.hermescontrol.NavigationController
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.data.model.Attachment
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
import com.m57.hermescontrol.data.ws.CommandCatalog
|
import com.m57.hermescontrol.data.ws.CommandCatalog
|
||||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||||
import com.m57.hermescontrol.notification.NotificationHelper
|
import com.m57.hermescontrol.notification.NotificationHelper
|
||||||
|
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||||
import com.m57.hermescontrol.theme.StatusRed
|
import com.m57.hermescontrol.theme.StatusRed
|
||||||
import com.m57.hermescontrol.ui.common.EmptyState
|
import com.m57.hermescontrol.ui.common.EmptyState
|
||||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||||
@@ -360,6 +362,7 @@ fun ChatScreen(
|
|||||||
ChatTopBanner(
|
ChatTopBanner(
|
||||||
connectionStatus = state.connectionStatus,
|
connectionStatus = state.connectionStatus,
|
||||||
onReconnect = viewModel::reconnect,
|
onReconnect = viewModel::reconnect,
|
||||||
|
onAuthExpired = { NavigationController.navigateTo(AuthLoginScreen) },
|
||||||
)
|
)
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
@@ -1337,18 +1340,20 @@ private fun ChatLifecycleEffects(
|
|||||||
private fun ChatTopBanner(
|
private fun ChatTopBanner(
|
||||||
connectionStatus: ConnectionStatus,
|
connectionStatus: ConnectionStatus,
|
||||||
onReconnect: () -> Unit,
|
onReconnect: () -> Unit,
|
||||||
|
onAuthExpired: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val isShown =
|
||||||
|
connectionStatus != ConnectionStatus.CONNECTED &&
|
||||||
|
connectionStatus != ConnectionStatus.CONNECTING
|
||||||
androidx.compose.animation.AnimatedVisibility(
|
androidx.compose.animation.AnimatedVisibility(
|
||||||
visible =
|
visible = isShown,
|
||||||
connectionStatus == ConnectionStatus.RECONNECTING ||
|
|
||||||
connectionStatus == ConnectionStatus.DISCONNECTED,
|
|
||||||
enter = androidx.compose.animation.expandVertically() + androidx.compose.animation.fadeIn(),
|
enter = androidx.compose.animation.expandVertically() + androidx.compose.animation.fadeIn(),
|
||||||
exit = androidx.compose.animation.shrinkVertically() + androidx.compose.animation.fadeOut(),
|
exit = androidx.compose.animation.shrinkVertically() + androidx.compose.animation.fadeOut(),
|
||||||
) {
|
) {
|
||||||
androidx.compose.material3.Surface(
|
androidx.compose.material3.Surface(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
color = MaterialTheme.colorScheme.errorContainer,
|
color = LocalHermesStatusColors.current.errorContainer,
|
||||||
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
contentColor = LocalHermesStatusColors.current.error,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -1358,26 +1363,53 @@ private fun ChatTopBanner(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
) {
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (connectionStatus == ConnectionStatus.RECONNECTING) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(16.dp).padding(end = 8.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = LocalHermesStatusColors.current.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
text =
|
text =
|
||||||
if (connectionStatus == ConnectionStatus.RECONNECTING) {
|
when (connectionStatus) {
|
||||||
stringResource(R.string.chat_status_reconnecting)
|
ConnectionStatus.RECONNECTING -> stringResource(R.string.chat_status_reconnecting)
|
||||||
} else {
|
ConnectionStatus.DISCONNECTED -> stringResource(R.string.chat_status_disconnected)
|
||||||
stringResource(R.string.chat_status_disconnected)
|
ConnectionStatus.NO_NETWORK -> stringResource(R.string.chat_status_no_network)
|
||||||
|
ConnectionStatus.AUTH_EXPIRED -> stringResource(R.string.chat_status_auth_expired)
|
||||||
|
else -> ""
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
)
|
||||||
if (connectionStatus == ConnectionStatus.DISCONNECTED) {
|
}
|
||||||
|
when (connectionStatus) {
|
||||||
|
ConnectionStatus.DISCONNECTED,
|
||||||
|
ConnectionStatus.NO_NETWORK,
|
||||||
|
-> {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = onReconnect,
|
onClick = onReconnect,
|
||||||
colors =
|
colors =
|
||||||
androidx.compose.material3.ButtonDefaults.textButtonColors(
|
androidx.compose.material3.ButtonDefaults.textButtonColors(
|
||||||
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
contentColor = LocalHermesStatusColors.current.error,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.chat_action_reconnect))
|
Text(stringResource(R.string.chat_action_reconnect))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ConnectionStatus.AUTH_EXPIRED -> {
|
||||||
|
TextButton(
|
||||||
|
onClick = onAuthExpired,
|
||||||
|
colors =
|
||||||
|
androidx.compose.material3.ButtonDefaults.textButtonColors(
|
||||||
|
contentColor = LocalHermesStatusColors.current.error,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.chat_action_sign_in))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> { /* RECONNECTING: auto, no button */ }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,11 +82,14 @@
|
|||||||
<string name="chat_action_search">Search</string>
|
<string name="chat_action_search">Search</string>
|
||||||
<string name="chat_action_close_search">Close search</string>
|
<string name="chat_action_close_search">Close search</string>
|
||||||
<string name="chat_action_reconnect">Reconnect</string>
|
<string name="chat_action_reconnect">Reconnect</string>
|
||||||
|
<string name="chat_action_sign_in">Sign in again</string>
|
||||||
<string name="chat_empty_title">Ready to chat</string>
|
<string name="chat_empty_title">Ready to chat</string>
|
||||||
<string name="chat_empty_subtitle">Send a message to start a conversation</string>
|
<string name="chat_empty_subtitle">Send a message to start a conversation</string>
|
||||||
<string name="chat_status_connecting">Connecting to Hermes\u2026</string>
|
<string name="chat_status_connecting">Connecting to Hermes\u2026</string>
|
||||||
<string name="chat_status_reconnecting">Reconnecting to Hermes\u2026</string>
|
<string name="chat_status_reconnecting">Reconnecting to Hermes\u2026</string>
|
||||||
<string name="chat_status_disconnected">Disconnected from Hermes</string>
|
<string name="chat_status_disconnected">Disconnected from Hermes</string>
|
||||||
|
<string name="chat_status_no_network">No network connection</string>
|
||||||
|
<string name="chat_status_auth_expired">Session expired \u2014 please sign in again</string>
|
||||||
<string name="chat_thinking">Thinking\u2026</string>
|
<string name="chat_thinking">Thinking\u2026</string>
|
||||||
<string name="chat_thinking_param">Thinking: %1$s\u2026</string>
|
<string name="chat_thinking_param">Thinking: %1$s\u2026</string>
|
||||||
<string name="chat_input_placeholder_not_connected">Not connected\u2026</string>
|
<string name="chat_input_placeholder_not_connected">Not connected\u2026</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user