refactor(#285): extract section composables from SettingsScreen and ChatScreen (#358)

* refactor(#285): extract ConnectionSection from SettingsScreen

* refactor(#285): extract TestResultCard, SaveIndicator, SettingsActionButtons

* refactor(#285): extract BehaviorSection from SettingsScreen

* refactor(#285): extract AppearanceSection from SettingsScreen

* refactor(#285): extract ChatSection from SettingsScreen

* refactor(#285): extract NavBarSection from SettingsScreen

* refactor(#285): extract AboutSection from SettingsScreen

* refactor(#285): extract ChatLifecycleEffects from ChatScreen

* refactor(#285): extract ChatTopBanner from ChatScreen

* refactor(#285): extract ChatMessageList, ChatLoadingOverlay, ChatScrollToBottomFab

* fix(#285): use fully-qualified ButtonDefaults, BoxScope receiver for ChatScrollToBottomFab

* fix(#285): add BoxScope import

* fix(#285): pass isLoading as param instead of accessing StateFlow.value

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-06-25 20:36:21 +03:00
1 parent a3b794be5c
commit 9297413b2a
2 files changed
+694 -445

No files matched your search

@@ -25,6 +25,7 @@ import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@@ -123,7 +124,6 @@ fun ChatScreen(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
var lastSessionId by remember { mutableStateOf<String?>(null) }
val showScrollToBottom by remember {
derivedStateOf {
state.messages.isNotEmpty() && listState.canScrollForward
@@ -135,116 +135,24 @@ fun ChatScreen(
val snackbarHostState = remember { SnackbarHostState() }
val isDark = isSystemInDarkTheme()
val context = LocalContext.current
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
// Publish the notification session ID to the ViewModel synchronously
// during composition — BEFORE any WebSocket event (GatewayReady) can be
// processed. This ensures GatewayReady sees the pending session and
// resumes it instead of creating a new empty chat (issue #240).
SideEffect {
viewModel.initialSessionId = sessionId
}
// Switch to the session from a notification tap or history screen when provided.
// Keyed on connectionStatus so switchSession only fires after the WS is
// connected — otherwise SESSION_RESUME is silently dropped (webSocket is
// null before onOpen). GatewayReady also handles initialSessionId as a
// fallback for the race where it fires before this effect.
val pendingSessionId = NavigationController.pendingSessionId
LaunchedEffect(sessionId, pendingSessionId, state.connectionStatus) {
if (state.connectionStatus != ConnectionStatus.CONNECTED) return@LaunchedEffect
val target = if (!sessionId.isNullOrBlank()) sessionId else pendingSessionId
if (!target.isNullOrBlank()) {
viewModel.switchSession(target)
if (target == pendingSessionId) {
NavigationController.pendingSessionId = null
}
}
}
// Manage notification foreground service lifecycle:
// - When app goes to background (ON_STOP), mark as not-foreground and
// start the notification service so we can post reply notifications.
// - When app returns to foreground (ON_START), mark as foreground and
// stop the notification service.
DisposableEffect(lifecycleOwner) {
val observer =
androidx.lifecycle.LifecycleEventObserver { _, event ->
when (event) {
androidx.lifecycle.Lifecycle.Event.ON_START -> {
NotificationHelper.setAppForeground(context, true)
NotificationHelper.stop(context)
viewModel.refreshSettings()
viewModel.refreshCurrentSession()
}
androidx.lifecycle.Lifecycle.Event.ON_STOP -> {
NotificationHelper.setAppForeground(context, false)
NotificationHelper.start(context)
}
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
// Request POST_NOTIFICATIONS runtime permission on Android 13+ (API 33).
// The manifest declaration alone is insufficient — on API 33+ the system
// requires a runtime permission prompt before the app can post any
// notification, including the foreground service notification.
val requestNotificationPermission =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { /* granted — next lifecycle event will pick it up */ }
LaunchedEffect(Unit) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val permission = android.Manifest.permission.POST_NOTIFICATIONS
if (
ContextCompat.checkSelfPermission(context, permission) !=
android.content.pm.PackageManager.PERMISSION_GRANTED
) {
requestNotificationPermission.launch(permission)
}
}
}
// Auto-scroll to bottom on new messages
LaunchedEffect(state.messages.size, state.streamingMessage?.content?.length, state.isThinking) {
val totalItems =
state.messages.size +
(if (state.streamingMessage != null) 1 else 0) +
(if (state.isThinking) 1 else 0)
if (totalItems > 0) {
val isSessionSwitch = state.currentSessionId != lastSessionId
if (isSessionSwitch) {
lastSessionId = state.currentSessionId
listState.scrollToItem(totalItems - 1)
} else if (listState.isAtBottom()) {
listState.animateScrollToItem(totalItems - 1)
}
}
}
// Show error as snackbar
LaunchedEffect(state.errorMessage) {
state.errorMessage?.let { error ->
snackbarHostState.showSnackbar(error)
viewModel.clearError()
}
}
// Clarify dialog
state.clarifyRequest?.let { clarify ->
ClarifyDialog(
clarify = clarify,
onOptionSelected = viewModel::respondToClarify,
onDismiss = viewModel::dismissClarify,
// Lifecycle effects, permissions, session switching, auto-scroll, errors
ChatLifecycleEffects(
sessionId = sessionId,
connectionStatus = state.connectionStatus,
currentSessionId = state.currentSessionId,
messages = state.messages,
streamingMessage = state.streamingMessage,
isThinking = state.isThinking,
errorMessage = state.errorMessage,
isSearchActive = state.isSearchActive,
currentSearchMatchIndex = state.currentSearchMatchIndex,
searchMatchIndices = state.searchMatchIndices,
clarifyRequest = state.clarifyRequest,
listState = listState,
snackbarHostState = snackbarHostState,
viewModel = viewModel,
)
}
val backgroundGradient =
Brush.verticalGradient(
@@ -256,20 +164,6 @@ fun ChatScreen(
),
)
// Scroll to current search match
LaunchedEffect(state.isSearchActive, state.currentSearchMatchIndex, state.searchMatchIndices) {
if (state.isSearchActive &&
state.currentSearchMatchIndex >= 0 &&
state.currentSearchMatchIndex < state.searchMatchIndices.size &&
state.messages.isNotEmpty()
) {
val targetIndex = state.searchMatchIndices[state.currentSearchMatchIndex]
listState.animateScrollToItem(
targetIndex.coerceIn(0, state.messages.lastIndex),
)
}
}
HermesScaffold(
modifier = modifier,
pinTopBar = true,
@@ -339,78 +233,10 @@ fun ChatScreen(
.background(backgroundGradient)
.imePadding(),
) {
androidx.compose.animation.AnimatedVisibility(
visible =
state.connectionStatus == ConnectionStatus.RECONNECTING ||
state.connectionStatus == ConnectionStatus.DISCONNECTED,
enter = androidx.compose.animation.expandVertically() + androidx.compose.animation.fadeIn(),
exit = androidx.compose.animation.shrinkVertically() + androidx.compose.animation.fadeOut(),
) {
androidx.compose.material3.Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text =
if (state.connectionStatus == ConnectionStatus.RECONNECTING) {
stringResource(R.string.chat_status_reconnecting)
} else {
stringResource(R.string.chat_status_disconnected)
},
style = MaterialTheme.typography.bodyMedium,
ChatTopBanner(
connectionStatus = state.connectionStatus,
onReconnect = viewModel::reconnect,
)
if (state.connectionStatus == ConnectionStatus.DISCONNECTED) {
TextButton(
onClick = { viewModel.reconnect() },
colors =
androidx.compose.material3.ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
) {
Text(stringResource(R.string.chat_action_reconnect))
}
}
}
}
}
androidx.compose.animation.AnimatedVisibility(
visible = state.isSearchActive,
enter = androidx.compose.animation.expandVertically() + androidx.compose.animation.fadeIn(),
exit = androidx.compose.animation.shrinkVertically() + androidx.compose.animation.fadeOut(),
) {
androidx.compose.material3.Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceContainerLow,
tonalElevation = 2.dp,
border =
BorderStroke(
width = 1.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.12f),
),
) {
Box(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
SearchBarRow(
searchQuery = state.searchQuery,
onQueryChange = { viewModel.setSearchQuery(it) },
searchMatchCount = state.searchMatchIndices.size,
currentMatchIndex = state.currentSearchMatchIndex,
onNavigateUp = { viewModel.navigateSearchMatch(-1) },
onNavigateDown = { viewModel.navigateSearchMatch(1) },
onClose = { viewModel.clearSearch() },
)
}
}
}
Box(
modifier =
@@ -418,153 +244,38 @@ fun ChatScreen(
.weight(1f)
.fillMaxWidth(),
) {
if (state.messages.isEmpty() && !state.isLoading) {
EmptyState(
title = stringResource(R.string.chat_empty_title),
subtitle = stringResource(R.string.chat_empty_subtitle),
)
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp),
) {
itemsIndexed(
items = state.messages,
key = { _, message -> message.id },
) { index, message ->
val isCurrentMatch =
state.isSearchActive &&
state.currentSearchMatchIndex >= 0 &&
state.currentSearchMatchIndex < state.searchMatchIndices.size &&
state.searchMatchIndices[state.currentSearchMatchIndex] == index
val isLastMessage = index == state.messages.lastIndex
val isAssistant = message.role == MessageRole.ASSISTANT
if (state.typingEffectEnabled &&
isLastMessage &&
isAssistant &&
lastAnimatedMessageId != message.id
) {
StreamingBubbleWithTypingEffect(
streaming = message,
typingDelayMs = state.typingEffectDelayMs,
ChatMessageList(
messages = state.messages,
streamingMessage = state.streamingMessage,
isThinking = state.isThinking,
thinkingText = state.thinkingText,
isSearchActive = state.isSearchActive,
searchQuery = state.searchQuery,
currentSearchMatchIndex = state.currentSearchMatchIndex,
searchMatchIndices = state.searchMatchIndices,
typingEffectEnabled = state.typingEffectEnabled,
typingEffectDelayMs = state.typingEffectDelayMs,
isLoading = state.isLoading,
isDark = isDark,
onAnimationComplete = {
lastAnimatedMessageId = message.id
},
listState = listState,
lastAnimatedMessageId = lastAnimatedMessageId,
onLastAnimatedMessageIdChange = { lastAnimatedMessageId = it },
viewModel = viewModel,
)
} else {
ChatBubble(
message = message,
isDarkTheme = isDark,
searchQuery = if (state.isSearchActive) state.searchQuery else "",
isCurrentMatch = isCurrentMatch,
)
}
}
// Streaming message — rendered separately for O(1) updates
state.streamingMessage?.let { streaming ->
item(key = "streaming-${streaming.id}") {
if (state.typingEffectEnabled && streaming.isStreaming) {
StreamingBubbleWithTypingEffect(
streaming = streaming,
typingDelayMs = state.typingEffectDelayMs,
isDark = isDark,
)
} else {
ChatBubble(
message = streaming,
isDarkTheme = isDark,
searchQuery = "",
isCurrentMatch = false,
)
}
}
}
// Thinking indicator
if (state.isThinking) {
item(key = "thinking") {
ThinkingIndicator(state.thinkingText)
}
}
}
// Loading overlay
androidx.compose.animation.AnimatedVisibility(
visible = state.isLoading,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.fillMaxSize(),
) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.7f)),
contentAlignment = Alignment.Center,
) {
Card(
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(4.dp),
) {
Column(
modifier = Modifier.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.chat_status_connecting),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
ChatLoadingOverlay(isLoading = state.isLoading)
// Scroll-to-bottom FAB
androidx.compose.animation.AnimatedVisibility(
visible = showScrollToBottom,
enter = fadeIn() + scaleIn(),
exit = fadeOut() + scaleOut(),
modifier =
Modifier
.align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = 16.dp),
) {
FloatingActionButton(
onClick = {
scrollScope.launch {
val totalItems =
state.messages.size +
(if (state.streamingMessage != null) 1 else 0) +
(if (state.isThinking) 1 else 0)
if (totalItems > 0) {
listState.animateScrollToItem(totalItems - 1)
}
}
},
modifier = Modifier.size(40.dp),
containerColor = MaterialTheme.colorScheme.surfaceContainer,
contentColor = MaterialTheme.colorScheme.onSurface,
) {
Icon(
imageVector = Icons.Filled.KeyboardArrowDown,
contentDescription = stringResource(R.string.content_desc_scroll_to_bottom),
ChatScrollToBottomFab(
showScrollToBottom = showScrollToBottom,
scrollScope = scrollScope,
listState = listState,
messages = state.messages,
streamingMessage = state.streamingMessage,
isThinking = state.isThinking,
)
}
}
}
ChatInputBar(
inputText = inputText,
@@ -1128,3 +839,385 @@ private fun LazyListState.isAtBottom(threshold: Int = 3): Boolean {
val lastVisibleItem = visibleItems.last()
return lastVisibleItem.index >= layoutInfo.totalItemsCount - threshold
}
@Composable
private fun ChatLifecycleEffects(
sessionId: String?,
connectionStatus: ConnectionStatus,
currentSessionId: String?,
messages: List<ChatMessage>,
streamingMessage: ChatMessage?,
isThinking: Boolean,
errorMessage: String?,
isSearchActive: Boolean,
currentSearchMatchIndex: Int,
searchMatchIndices: List<Int>,
clarifyRequest: ClarifyUi?,
listState: LazyListState,
snackbarHostState: SnackbarHostState,
viewModel: ChatViewModel,
) {
val context = LocalContext.current
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
// Publish the notification session ID to the ViewModel synchronously
SideEffect {
viewModel.initialSessionId = sessionId
}
// Switch to session from notification/history
var lastSessionId by remember { mutableStateOf<String?>(null) }
val pendingSessionId = NavigationController.pendingSessionId
LaunchedEffect(sessionId, pendingSessionId, connectionStatus) {
if (connectionStatus != ConnectionStatus.CONNECTED) return@LaunchedEffect
val target = if (!sessionId.isNullOrBlank()) sessionId else pendingSessionId
if (!target.isNullOrBlank()) {
viewModel.switchSession(target)
if (target == pendingSessionId) {
NavigationController.pendingSessionId = null
}
}
}
// Lifecycle observer for notification foreground service
DisposableEffect(lifecycleOwner) {
val observer =
androidx.lifecycle.LifecycleEventObserver { _, event ->
when (event) {
androidx.lifecycle.Lifecycle.Event.ON_START -> {
NotificationHelper.setAppForeground(context, true)
NotificationHelper.stop(context)
viewModel.refreshSettings()
viewModel.refreshCurrentSession()
}
androidx.lifecycle.Lifecycle.Event.ON_STOP -> {
NotificationHelper.setAppForeground(context, false)
NotificationHelper.start(context)
}
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
// Request POST_NOTIFICATIONS permission on Android 13+
val requestNotificationPermission =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { /* granted */ }
LaunchedEffect(Unit) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val permission = android.Manifest.permission.POST_NOTIFICATIONS
if (ContextCompat.checkSelfPermission(context, permission) !=
android.content.pm.PackageManager.PERMISSION_GRANTED
) {
requestNotificationPermission.launch(permission)
}
}
}
// Auto-scroll to bottom on new messages
LaunchedEffect(messages.size, streamingMessage?.content?.length, isThinking) {
val totalItems =
messages.size +
(if (streamingMessage != null) 1 else 0) +
(if (isThinking) 1 else 0)
if (totalItems > 0) {
val isSessionSwitch = currentSessionId != lastSessionId
if (isSessionSwitch) {
lastSessionId = currentSessionId
listState.scrollToItem(totalItems - 1)
} else if (listState.isAtBottom()) {
listState.animateScrollToItem(totalItems - 1)
}
}
}
// Show error as snackbar
LaunchedEffect(errorMessage) {
errorMessage?.let { error ->
snackbarHostState.showSnackbar(error)
viewModel.clearError()
}
}
// Clarify dialog
clarifyRequest?.let { clarify ->
ClarifyDialog(
clarify = clarify,
onOptionSelected = viewModel::respondToClarify,
onDismiss = viewModel::dismissClarify,
)
}
// Scroll to current search match
LaunchedEffect(isSearchActive, currentSearchMatchIndex, searchMatchIndices) {
if (isSearchActive &&
currentSearchMatchIndex >= 0 &&
currentSearchMatchIndex < searchMatchIndices.size &&
messages.isNotEmpty()
) {
val targetIndex = searchMatchIndices[currentSearchMatchIndex]
listState.animateScrollToItem(
targetIndex.coerceIn(0, messages.lastIndex),
)
}
}
}
@Composable
private fun ChatTopBanner(
connectionStatus: ConnectionStatus,
onReconnect: () -> Unit,
) {
androidx.compose.animation.AnimatedVisibility(
visible =
connectionStatus == ConnectionStatus.RECONNECTING ||
connectionStatus == ConnectionStatus.DISCONNECTED,
enter = androidx.compose.animation.expandVertically() + androidx.compose.animation.fadeIn(),
exit = androidx.compose.animation.shrinkVertically() + androidx.compose.animation.fadeOut(),
) {
androidx.compose.material3.Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text =
if (connectionStatus == ConnectionStatus.RECONNECTING) {
stringResource(R.string.chat_status_reconnecting)
} else {
stringResource(R.string.chat_status_disconnected)
},
style = MaterialTheme.typography.bodyMedium,
)
if (connectionStatus == ConnectionStatus.DISCONNECTED) {
TextButton(
onClick = onReconnect,
colors =
androidx.compose.material3.ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
) {
Text(stringResource(R.string.chat_action_reconnect))
}
}
}
}
}
}
@Composable
private fun ChatMessageList(
messages: List<ChatMessage>,
streamingMessage: ChatMessage?,
isThinking: Boolean,
thinkingText: String,
isSearchActive: Boolean,
searchQuery: String,
currentSearchMatchIndex: Int,
searchMatchIndices: List<Int>,
typingEffectEnabled: Boolean,
typingEffectDelayMs: Int,
isLoading: Boolean,
isDark: Boolean,
listState: LazyListState,
lastAnimatedMessageId: String?,
onLastAnimatedMessageIdChange: (String?) -> Unit,
viewModel: ChatViewModel,
) {
// Search bar
androidx.compose.animation.AnimatedVisibility(
visible = isSearchActive,
enter = androidx.compose.animation.expandVertically() + androidx.compose.animation.fadeIn(),
exit = androidx.compose.animation.shrinkVertically() + androidx.compose.animation.fadeOut(),
) {
androidx.compose.material3.Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceContainerLow,
tonalElevation = 2.dp,
border =
BorderStroke(
width = 1.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.12f),
),
) {
Box(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
SearchBarRow(
searchQuery = searchQuery,
onQueryChange = { viewModel.setSearchQuery(it) },
searchMatchCount = searchMatchIndices.size,
currentMatchIndex = currentSearchMatchIndex,
onNavigateUp = { viewModel.navigateSearchMatch(-1) },
onNavigateDown = { viewModel.navigateSearchMatch(1) },
onClose = { viewModel.clearSearch() },
)
}
}
}
if (messages.isEmpty() && !isLoading) {
EmptyState(
title = stringResource(R.string.chat_empty_title),
subtitle = stringResource(R.string.chat_empty_subtitle),
)
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp),
) {
itemsIndexed(
items = messages,
key = { _, message -> message.id },
) { index, message ->
val isCurrentMatch =
isSearchActive &&
currentSearchMatchIndex >= 0 &&
currentSearchMatchIndex < searchMatchIndices.size &&
searchMatchIndices[currentSearchMatchIndex] == index
val isLastMessage = index == messages.lastIndex
val isAssistant = message.role == MessageRole.ASSISTANT
if (typingEffectEnabled && isLastMessage && isAssistant &&
lastAnimatedMessageId != message.id
) {
StreamingBubbleWithTypingEffect(
streaming = message,
typingDelayMs = typingEffectDelayMs,
isDark = isDark,
onAnimationComplete = {
onLastAnimatedMessageIdChange(message.id)
},
)
} else {
ChatBubble(
message = message,
isDarkTheme = isDark,
searchQuery = if (isSearchActive) searchQuery else "",
isCurrentMatch = isCurrentMatch,
)
}
}
// Streaming message
streamingMessage?.let { streaming ->
item(key = "streaming-${streaming.id}") {
if (typingEffectEnabled && streaming.isStreaming) {
StreamingBubbleWithTypingEffect(
streaming = streaming,
typingDelayMs = typingEffectDelayMs,
isDark = isDark,
)
} else {
ChatBubble(
message = streaming,
isDarkTheme = isDark,
searchQuery = "",
isCurrentMatch = false,
)
}
}
}
// Thinking indicator
if (isThinking) {
item(key = "thinking") {
ThinkingIndicator(thinkingText)
}
}
}
}
@Composable
private fun ChatLoadingOverlay(isLoading: Boolean) {
androidx.compose.animation.AnimatedVisibility(
visible = isLoading,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.fillMaxSize(),
) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.7f)),
contentAlignment = Alignment.Center,
) {
Card(
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(4.dp),
) {
Column(
modifier = Modifier.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.chat_status_connecting),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
}
@Composable
private fun BoxScope.ChatScrollToBottomFab(
showScrollToBottom: Boolean,
scrollScope: kotlinx.coroutines.CoroutineScope,
listState: LazyListState,
messages: List<ChatMessage>,
streamingMessage: ChatMessage?,
isThinking: Boolean,
) {
androidx.compose.animation.AnimatedVisibility(
visible = showScrollToBottom,
enter = fadeIn() + scaleIn(),
exit = fadeOut() + scaleOut(),
modifier =
Modifier
.align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = 16.dp),
) {
FloatingActionButton(
onClick = {
scrollScope.launch {
val totalItems =
messages.size +
(if (streamingMessage != null) 1 else 0) +
(if (isThinking) 1 else 0)
if (totalItems > 0) {
listState.animateScrollToItem(totalItems - 1)
}
}
},
modifier = Modifier.size(40.dp),
containerColor = MaterialTheme.colorScheme.surfaceContainer,
contentColor = MaterialTheme.colorScheme.onSurface,
) {
Icon(
imageVector = Icons.Filled.KeyboardArrowDown,
contentDescription = stringResource(R.string.content_desc_scroll_to_bottom),
)
}
}
}
@@ -134,9 +134,185 @@ fun SettingsScreen(
) {
when (selectedTab) {
SettingsTab.CONNECTION -> {
// Connection section
ConnectionSection(
state = state,
viewModel = viewModel,
passwordVisible = passwordVisible,
onPasswordVisibilityToggle = { passwordVisible = !passwordVisible },
)
TestResultCard(testResult = state.testResult)
SaveIndicator(isSaved = state.isSaved)
SettingsActionButtons(
isTesting = state.isTesting,
onTest = viewModel::testConnection,
onSave = viewModel::save,
)
Spacer(modifier = Modifier.height(16.dp))
}
SettingsTab.BEHAVIOR -> {
BehaviorSection(
autoReconnect = state.autoReconnect,
onAutoReconnectChange = viewModel::onAutoReconnectChange,
)
}
SettingsTab.APPEARANCE -> {
AppearanceSection(
themePreference = state.themePreference,
onThemeChange = viewModel::onThemeChange,
useDynamicColors = state.useDynamicColors,
onUseDynamicColorsChange = viewModel::onUseDynamicColorsChange,
themePreset = state.themePreset,
onThemePresetChange = viewModel::onThemePresetChange,
)
ChatSection(
typingEffectEnabled = state.typingEffectEnabled,
onTypingEffectEnabledChange = viewModel::onTypingEffectEnabledChange,
typingEffectDelayMs = state.typingEffectDelayMs,
onTypingEffectDelayMsChange = viewModel::onTypingEffectDelayMsChange,
)
NavBarSection(
bottomNavDisplayMode = state.bottomNavDisplayMode,
onBottomNavDisplayModeChange = viewModel::onBottomNavDisplayModeChange,
selectedNavItems = state.selectedNavItems,
availableNavItems = viewModel.availableNavItems,
onReorderNavItems = viewModel::reorderNavItems,
onRemoveNavItem = viewModel::removeNavItem,
onAddNavItem = viewModel::addNavItem,
)
}
SettingsTab.ABOUT -> {
// ── About section ─────────────────────────────────────────
SectionCard(title = stringResource(R.string.settings_sec_about)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(R.string.settings_about_app_name),
style =
MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.Bold,
),
)
}
Spacer(modifier = Modifier.height(8.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(8.dp))
InfoRow(
label = stringResource(R.string.settings_about_version),
value = "v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
)
InfoRow(
label = stringResource(R.string.settings_about_build),
value =
if (BuildConfig.DEBUG) {
stringResource(R.string.settings_about_debug)
} else {
stringResource(R.string.settings_about_release)
},
)
if (BuildConfig.GIT_SHA.isNotBlank()) {
InfoRow(
label = stringResource(R.string.settings_about_commit),
value = BuildConfig.GIT_SHA,
)
}
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "https://github.com/Hy4ri/hermes-mobile",
style =
MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.primary,
),
)
Spacer(modifier = Modifier.height(24.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
viewModel.logout()
onLogout()
},
modifier = Modifier.fillMaxWidth(),
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
),
) {
Text(stringResource(R.string.settings_logout))
}
}
}
}
}
}
}
}
@Composable
private fun InfoRow(
label: String,
value: String,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
)
}
}
@Composable
private fun SectionCard(
title: String,
content: @Composable () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(1.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = title,
style =
MaterialTheme.typography.titleSmall.copy(
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
),
modifier = Modifier.padding(bottom = 12.dp),
)
content()
}
}
}
@Composable
private fun ConnectionSection(
state: SettingsUiState,
viewModel: SettingsViewModel,
passwordVisible: Boolean,
onPasswordVisibilityToggle: () -> Unit,
) {
SectionCard(title = stringResource(R.string.settings_sec_connection)) {
// Connection Profiles management in Settings
if (state.profiles.isNotEmpty()) {
var profilesExpanded by remember { mutableStateOf(false) }
Text(
@@ -268,7 +444,7 @@ fun SettingsScreen(
PasswordVisualTransformation()
},
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
IconButton(onClick = onPasswordVisibilityToggle) {
Icon(
imageVector =
if (passwordVisible) {
@@ -310,14 +486,16 @@ fun SettingsScreen(
Text(stringResource(R.string.settings_action_clear_token))
}
}
}
// Test result
@Composable
private fun TestResultCard(testResult: String?) {
AnimatedVisibility(
visible = state.testResult != null,
visible = testResult != null,
enter = fadeIn(),
exit = fadeOut(),
) {
state.testResult?.let { result ->
testResult?.let { result ->
Card(
colors =
CardDefaults.cardColors(
@@ -338,10 +516,12 @@ fun SettingsScreen(
}
}
}
}
// Save indicator
@Composable
private fun SaveIndicator(isSaved: Boolean) {
AnimatedVisibility(
visible = state.isSaved,
visible = isSaved,
enter = fadeIn(),
exit = fadeOut(),
) {
@@ -353,18 +533,24 @@ fun SettingsScreen(
),
)
}
}
// Buttons
@Composable
private fun SettingsActionButtons(
isTesting: Boolean,
onTest: () -> Unit,
onSave: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedButton(
onClick = viewModel::testConnection,
enabled = !state.isTesting,
onClick = onTest,
enabled = !isTesting,
modifier = Modifier.weight(1f),
) {
if (state.isTesting) {
if (isTesting) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
@@ -375,7 +561,7 @@ fun SettingsScreen(
}
Button(
onClick = viewModel::save,
onClick = onSave,
modifier = Modifier.weight(1f),
colors =
ButtonDefaults.buttonColors(
@@ -385,11 +571,13 @@ fun SettingsScreen(
Text(stringResource(R.string.action_save))
}
}
}
Spacer(modifier = Modifier.height(16.dp))
}
SettingsTab.BEHAVIOR -> {
// Behavior section
@Composable
private fun BehaviorSection(
autoReconnect: Boolean,
onAutoReconnectChange: (Boolean) -> Unit,
) {
SectionCard(title = stringResource(R.string.settings_sec_behavior)) {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -410,8 +598,8 @@ fun SettingsScreen(
)
}
Switch(
checked = state.autoReconnect,
onCheckedChange = viewModel::onAutoReconnectChange,
checked = autoReconnect,
onCheckedChange = onAutoReconnectChange,
colors =
SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.primary,
@@ -420,9 +608,17 @@ fun SettingsScreen(
)
}
}
}
SettingsTab.APPEARANCE -> {
// Appearance section
}
@Composable
private fun AppearanceSection(
themePreference: ThemePreference,
onThemeChange: (ThemePreference) -> Unit,
useDynamicColors: Boolean,
onUseDynamicColorsChange: (Boolean) -> Unit,
themePreset: ThemePreset,
onThemePresetChange: (ThemePreset) -> Unit,
) {
SectionCard(title = stringResource(R.string.settings_sec_appearance)) {
Text(
text = stringResource(R.string.settings_item_theme),
@@ -432,8 +628,8 @@ fun SettingsScreen(
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
ThemePreference.entries.forEachIndexed { index, pref ->
SegmentedButton(
selected = state.themePreference == pref,
onClick = { viewModel.onThemeChange(pref) },
selected = themePreference == pref,
onClick = { onThemeChange(pref) },
shape =
SegmentedButtonDefaults.itemShape(
index = index,
@@ -472,8 +668,8 @@ fun SettingsScreen(
)
}
Switch(
checked = state.useDynamicColors,
onCheckedChange = viewModel::onUseDynamicColorsChange,
checked = useDynamicColors,
onCheckedChange = onUseDynamicColorsChange,
colors =
SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.primary,
@@ -497,7 +693,7 @@ fun SettingsScreen(
modifier = Modifier.fillMaxWidth(),
) {
Text(
when (state.themePreset) {
when (themePreset) {
ThemePreset.DEFAULT -> stringResource(R.string.theme_preset_default)
ThemePreset.MONOCHROME -> stringResource(R.string.theme_preset_monochrome)
ThemePreset.GRUVBOX -> stringResource(R.string.theme_preset_gruvbox)
@@ -540,7 +736,7 @@ fun SettingsScreen(
)
},
onClick = {
viewModel.onThemePresetChange(preset)
onThemePresetChange(preset)
presetsExpanded = false
},
)
@@ -548,8 +744,15 @@ fun SettingsScreen(
}
}
}
}
// Chat section
@Composable
private fun ChatSection(
typingEffectEnabled: Boolean,
onTypingEffectEnabledChange: (Boolean) -> Unit,
typingEffectDelayMs: Int,
onTypingEffectDelayMsChange: (Int) -> Unit,
) {
SectionCard(title = stringResource(R.string.settings_sec_chat)) {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -570,8 +773,8 @@ fun SettingsScreen(
)
}
Switch(
checked = state.typingEffectEnabled,
onCheckedChange = viewModel::onTypingEffectEnabledChange,
checked = typingEffectEnabled,
onCheckedChange = onTypingEffectEnabledChange,
colors =
SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.primary,
@@ -581,20 +784,20 @@ fun SettingsScreen(
}
// Delay slider — only visible when effect is enabled
AnimatedVisibility(visible = state.typingEffectEnabled) {
AnimatedVisibility(visible = typingEffectEnabled) {
Column(modifier = Modifier.padding(top = 12.dp)) {
Text(
text =
stringResource(
R.string.settings_item_typing_delay,
state.typingEffectDelayMs,
typingEffectDelayMs,
),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(4.dp))
Slider(
value = state.typingEffectDelayMs.toFloat(),
onValueChange = { viewModel.onTypingEffectDelayMsChange(it.toInt()) },
value = typingEffectDelayMs.toFloat(),
onValueChange = { onTypingEffectDelayMsChange(it.toInt()) },
valueRange = 10f..100f,
steps = 8, // 10, 20, 30, 40, 50, 60, 70, 80, 90, 100
modifier = Modifier.fillMaxWidth(),
@@ -621,8 +824,18 @@ fun SettingsScreen(
}
}
}
}
// Navigation Bar section
@Composable
private fun NavBarSection(
bottomNavDisplayMode: BottomNavDisplayMode,
onBottomNavDisplayModeChange: (BottomNavDisplayMode) -> Unit,
selectedNavItems: List<String>,
availableNavItems: List<Pair<String, Int>>,
onReorderNavItems: (List<String>) -> Unit,
onRemoveNavItem: (String) -> Unit,
onAddNavItem: (String) -> Unit,
) {
SectionCard(title = stringResource(R.string.settings_sec_nav_bar)) {
Text(
text = stringResource(R.string.settings_item_bottom_nav_display_mode),
@@ -632,8 +845,8 @@ fun SettingsScreen(
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
BottomNavDisplayMode.entries.forEachIndexed { index, mode ->
SegmentedButton(
selected = state.bottomNavDisplayMode == mode,
onClick = { viewModel.onBottomNavDisplayModeChange(mode) },
selected = bottomNavDisplayMode == mode,
onClick = { onBottomNavDisplayModeChange(mode) },
shape =
SegmentedButtonDefaults.itemShape(
index = index,
@@ -670,13 +883,13 @@ fun SettingsScreen(
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.settings_item_nav_items, state.selectedNavItems.size),
text = stringResource(R.string.settings_item_nav_items, selectedNavItems.size),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(8.dp))
state.selectedNavItems.forEachIndexed { index, name ->
val labelRes = viewModel.availableNavItems.firstOrNull { it.first == name }?.second
selectedNavItems.forEachIndexed { index, name ->
val labelRes = availableNavItems.firstOrNull { it.first == name }?.second
val label = if (labelRes != null) stringResource(labelRes) else name
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
@@ -691,11 +904,11 @@ fun SettingsScreen(
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
IconButton(
onClick = {
val list = state.selectedNavItems.toMutableList()
val list = selectedNavItems.toMutableList()
val temp = list[index]
list[index] = list[index - 1]
list[index - 1] = temp
viewModel.reorderNavItems(list)
onReorderNavItems(list)
},
enabled = index > 0,
) {
@@ -706,20 +919,20 @@ fun SettingsScreen(
}
IconButton(
onClick = {
val list = state.selectedNavItems.toMutableList()
val list = selectedNavItems.toMutableList()
val temp = list[index]
list[index] = list[index + 1]
list[index + 1] = temp
viewModel.reorderNavItems(list)
onReorderNavItems(list)
},
enabled = index < state.selectedNavItems.lastIndex,
enabled = index < selectedNavItems.lastIndex,
) {
Icon(
Icons.Filled.KeyboardArrowDown,
contentDescription = stringResource(R.string.content_desc_move_down),
)
}
IconButton(onClick = { viewModel.removeNavItem(name) }) {
IconButton(onClick = { onRemoveNavItem(name) }) {
Icon(
Icons.Filled.Close,
contentDescription =
@@ -733,9 +946,9 @@ fun SettingsScreen(
}
}
if (state.selectedNavItems.size < 5) {
if (selectedNavItems.size < 5) {
val available =
viewModel.availableNavItems.filter { it.first !in state.selectedNavItems }
availableNavItems.filter { it.first !in selectedNavItems }
if (available.isNotEmpty()) {
var expanded by remember { mutableStateOf(false) }
Box {
@@ -749,7 +962,7 @@ fun SettingsScreen(
DropdownMenuItem(
text = { Text(stringResource(labelRes)) },
onClick = {
viewModel.addNavItem(name)
onAddNavItem(name)
expanded = false
},
)
@@ -765,9 +978,10 @@ fun SettingsScreen(
)
}
}
}
SettingsTab.ABOUT -> {
// ── About section ─────────────────────────────────────────
}
@Composable
private fun AboutSection(onLogout: () -> Unit) {
SectionCard(title = stringResource(R.string.settings_sec_about)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
@@ -817,10 +1031,7 @@ fun SettingsScreen(
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
viewModel.logout()
onLogout()
},
onClick = onLogout,
modifier = Modifier.fillMaxWidth(),
colors =
ButtonDefaults.buttonColors(
@@ -830,59 +1041,4 @@ fun SettingsScreen(
Text(stringResource(R.string.settings_logout))
}
}
}
}
}
}
}
}
@Composable
private fun InfoRow(
label: String,
value: String,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
)
}
}
@Composable
private fun SectionCard(
title: String,
content: @Composable () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(1.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = title,
style =
MaterialTheme.typography.titleSmall.copy(
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
),
modifier = Modifier.padding(bottom = 12.dp),
)
content()
}
}
}