From 9297413b2aad7b2a4de49d13c85ddf1dd17705af Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:36:21 +0300 Subject: [PATCH] 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 --- .../m57/hermescontrol/ui/chat/ChatScreen.kt | 769 +++++---- .../ui/settings/SettingsScreen.kt | 1400 +++++++++-------- 2 files changed, 1209 insertions(+), 960 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 3a66760f..e26955dc 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -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(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, - ) - 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() }, - ) - } - } - } + ChatTopBanner( + connectionStatus = state.connectionStatus, + onReconnect = viewModel::reconnect, + ) Box( modifier = @@ -418,152 +244,37 @@ 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, - isDark = isDark, - onAnimationComplete = { - lastAnimatedMessageId = message.id - }, - ) - } 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) - } - } - } + 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, + listState = listState, + lastAnimatedMessageId = lastAnimatedMessageId, + onLastAnimatedMessageIdChange = { lastAnimatedMessageId = it }, + viewModel = viewModel, + ) // 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( @@ -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, + streamingMessage: ChatMessage?, + isThinking: Boolean, + errorMessage: String?, + isSearchActive: Boolean, + currentSearchMatchIndex: Int, + searchMatchIndices: List, + 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(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, + streamingMessage: ChatMessage?, + isThinking: Boolean, + thinkingText: String, + isSearchActive: Boolean, + searchQuery: String, + currentSearchMatchIndex: Int, + searchMatchIndices: List, + 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, + 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), + ) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt index ec9a3eec..cfae4e0f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt @@ -134,637 +134,55 @@ fun SettingsScreen( ) { when (selectedTab) { SettingsTab.CONNECTION -> { - // Connection section - SectionCard(title = stringResource(R.string.settings_sec_connection)) { - // Connection Profiles management in Settings - if (state.profiles.isNotEmpty()) { - var profilesExpanded by remember { mutableStateOf(false) } - Text( - text = stringResource(R.string.settings_item_profile), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Box(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { - OutlinedButton( - onClick = { profilesExpanded = true }, - modifier = Modifier.fillMaxWidth(), - ) { - val activeProfile = - state.profiles.firstOrNull { it.id == state.selectedProfileId } - Text( - text = - activeProfile?.name ?: stringResource( - R.string.settings_profile_default, - ), - ) - } - DropdownMenu( - expanded = profilesExpanded, - onDismissRequest = { profilesExpanded = false }, - modifier = Modifier.fillMaxWidth(0.85f), - ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.settings_profile_none)) }, - onClick = { - viewModel.selectProfile(null) - profilesExpanded = false - }, - ) - state.profiles.forEach { profile -> - DropdownMenuItem( - text = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text(profile.name) - IconButton( - onClick = { - viewModel.deleteProfile(profile.id) - profilesExpanded = false - }, - ) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = - stringResource( - R.string.content_desc_delete_profile, - ), - tint = MaterialTheme.colorScheme.error, - ) - } - } - }, - onClick = { - viewModel.selectProfile(profile.id) - profilesExpanded = false - }, - ) - } - } - } + ConnectionSection( + state = state, + viewModel = viewModel, + passwordVisible = passwordVisible, + onPasswordVisibilityToggle = { passwordVisible = !passwordVisible }, + ) - if (state.selectedProfileId != null) { - OutlinedTextField( - value = state.renameProfileName, - onValueChange = viewModel::onRenameProfileNameChange, - label = { Text(stringResource(R.string.settings_action_rename_profile)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - trailingIcon = { - Button( - onClick = viewModel::renameProfile, - modifier = Modifier.padding(end = 4.dp), - ) { - Text(stringResource(R.string.action_rename)) - } - }, - ) - Spacer(modifier = Modifier.height(12.dp)) - } - } - - OutlinedTextField( - value = state.host, - onValueChange = viewModel::onHostChange, - label = { Text(stringResource(R.string.settings_field_host)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - colors = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - cursorColor = MaterialTheme.colorScheme.primary, - ), - ) - - Spacer(modifier = Modifier.height(12.dp)) - - OutlinedTextField( - value = state.port, - onValueChange = viewModel::onPortChange, - label = { Text(stringResource(R.string.settings_field_port)) }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth(), - colors = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - cursorColor = MaterialTheme.colorScheme.primary, - ), - ) - - Spacer(modifier = Modifier.height(12.dp)) - - OutlinedTextField( - value = state.token, - onValueChange = viewModel::onTokenChange, - label = { Text(stringResource(R.string.settings_field_token)) }, - singleLine = true, - visualTransformation = - if (passwordVisible) { - VisualTransformation.None - } else { - PasswordVisualTransformation() - }, - trailingIcon = { - IconButton(onClick = { passwordVisible = !passwordVisible }) { - Icon( - imageVector = - if (passwordVisible) { - Icons.Filled.Visibility - } else { - Icons.Filled.VisibilityOff - }, - contentDescription = - if (passwordVisible) { - stringResource(R.string.content_desc_hide_token) - } else { - stringResource(R.string.content_desc_show_token) - }, - ) - } - }, - modifier = Modifier.fillMaxWidth(), - colors = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - cursorColor = MaterialTheme.colorScheme.primary, - ), - ) - - Spacer(modifier = Modifier.height(8.dp)) - - // B7 (Jun 18 2026): one-tap escape hatch for when the stored - // token is corrupted / rejected. Clears the token field and - // persists the empty value immediately so a stress-free - // re-pair is possible without manually selecting-and-deleting - // the masked password field character by character. - OutlinedButton( - onClick = { - viewModel.onTokenChange("") - viewModel.save() - }, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(R.string.settings_action_clear_token)) - } - } - - // Test result - AnimatedVisibility( - visible = state.testResult != null, - enter = fadeIn(), - exit = fadeOut(), - ) { - state.testResult?.let { result -> - Card( - colors = - CardDefaults.cardColors( - containerColor = - if (result.startsWith("✅")) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.errorContainer - }, - ), - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = result, - modifier = Modifier.padding(16.dp), - style = MaterialTheme.typography.bodyMedium, - ) - } - } - } - - // Save indicator - AnimatedVisibility( - visible = state.isSaved, - enter = fadeIn(), - exit = fadeOut(), - ) { - Text( - text = stringResource(R.string.settings_save_success), - style = - MaterialTheme.typography.bodyMedium.copy( - color = MaterialTheme.colorScheme.primary, - ), - ) - } - - // Buttons - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - OutlinedButton( - onClick = viewModel::testConnection, - enabled = !state.isTesting, - modifier = Modifier.weight(1f), - ) { - if (state.isTesting) { - CircularProgressIndicator( - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp, - ) - } else { - Text(stringResource(R.string.settings_action_test_connection)) - } - } - - Button( - onClick = viewModel::save, - modifier = Modifier.weight(1f), - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - ) { - Text(stringResource(R.string.action_save)) - } - } + 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 -> { - // Behavior section - SectionCard(title = stringResource(R.string.settings_sec_behavior)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column { - Text( - text = stringResource(R.string.settings_item_auto_reconnect), - style = MaterialTheme.typography.bodyLarge, - ) - Text( - text = stringResource(R.string.settings_desc_auto_reconnect), - style = - MaterialTheme.typography.bodySmall.copy( - color = MaterialTheme.colorScheme.onSurfaceVariant, - ), - ) - } - Switch( - checked = state.autoReconnect, - onCheckedChange = viewModel::onAutoReconnectChange, - colors = - SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colorScheme.primary, - checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, - ), - ) - } - } + BehaviorSection( + autoReconnect = state.autoReconnect, + onAutoReconnectChange = viewModel::onAutoReconnectChange, + ) } SettingsTab.APPEARANCE -> { - // Appearance section - SectionCard(title = stringResource(R.string.settings_sec_appearance)) { - Text( - text = stringResource(R.string.settings_item_theme), - style = MaterialTheme.typography.bodyLarge, - ) - Spacer(modifier = Modifier.height(8.dp)) - SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { - ThemePreference.entries.forEachIndexed { index, pref -> - SegmentedButton( - selected = state.themePreference == pref, - onClick = { viewModel.onThemeChange(pref) }, - shape = - SegmentedButtonDefaults.itemShape( - index = index, - count = ThemePreference.entries.size, - ), - ) { - Text( - when (pref) { - ThemePreference.SYSTEM -> stringResource(R.string.theme_system) - ThemePreference.LIGHT -> stringResource(R.string.theme_light) - ThemePreference.DARK -> stringResource(R.string.theme_dark) - }, - ) - } - } - } + AppearanceSection( + themePreference = state.themePreference, + onThemeChange = viewModel::onThemeChange, + useDynamicColors = state.useDynamicColors, + onUseDynamicColorsChange = viewModel::onUseDynamicColorsChange, + themePreset = state.themePreset, + onThemePresetChange = viewModel::onThemePresetChange, + ) - Spacer(modifier = Modifier.height(16.dp)) + ChatSection( + typingEffectEnabled = state.typingEffectEnabled, + onTypingEffectEnabledChange = viewModel::onTypingEffectEnabledChange, + typingEffectDelayMs = state.typingEffectDelayMs, + onTypingEffectDelayMsChange = viewModel::onTypingEffectDelayMsChange, + ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(R.string.settings_item_use_dynamic_colors), - style = MaterialTheme.typography.bodyLarge, - ) - Text( - text = stringResource(R.string.settings_desc_use_dynamic_colors), - style = - MaterialTheme.typography.bodySmall.copy( - color = MaterialTheme.colorScheme.onSurfaceVariant, - ), - ) - } - Switch( - checked = state.useDynamicColors, - onCheckedChange = viewModel::onUseDynamicColorsChange, - colors = - SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colorScheme.primary, - checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, - ), - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - - Text( - text = stringResource(R.string.settings_item_theme_preset), - style = MaterialTheme.typography.bodyLarge, - ) - Spacer(modifier = Modifier.height(8.dp)) - - var presetsExpanded by remember { mutableStateOf(false) } - Box(modifier = Modifier.fillMaxWidth()) { - OutlinedButton( - onClick = { presetsExpanded = true }, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - when (state.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) - ThemePreset.CATPPUCCIN -> stringResource(R.string.theme_preset_catppuccin) - ThemePreset.AMOLED -> stringResource(R.string.theme_preset_amoled) - }, - ) - } - DropdownMenu( - expanded = presetsExpanded, - onDismissRequest = { presetsExpanded = false }, - modifier = Modifier.fillMaxWidth(0.85f), - ) { - ThemePreset.entries.forEach { preset -> - DropdownMenuItem( - text = { - Text( - when (preset) { - ThemePreset.DEFAULT -> - stringResource( - R.string.theme_preset_default, - ) - ThemePreset.MONOCHROME -> - stringResource( - R.string.theme_preset_monochrome, - ) - ThemePreset.GRUVBOX -> - stringResource( - R.string.theme_preset_gruvbox, - ) - ThemePreset.CATPPUCCIN -> - stringResource( - R.string.theme_preset_catppuccin, - ) - ThemePreset.AMOLED -> - stringResource( - R.string.theme_preset_amoled, - ) - }, - ) - }, - onClick = { - viewModel.onThemePresetChange(preset) - presetsExpanded = false - }, - ) - } - } - } - } - - // Chat section - SectionCard(title = stringResource(R.string.settings_sec_chat)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column { - Text( - text = stringResource(R.string.settings_item_typing_effect), - style = MaterialTheme.typography.bodyLarge, - ) - Text( - text = stringResource(R.string.settings_desc_typing_effect), - style = - MaterialTheme.typography.bodySmall.copy( - color = MaterialTheme.colorScheme.onSurfaceVariant, - ), - ) - } - Switch( - checked = state.typingEffectEnabled, - onCheckedChange = viewModel::onTypingEffectEnabledChange, - colors = - SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colorScheme.primary, - checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, - ), - ) - } - - // Delay slider — only visible when effect is enabled - AnimatedVisibility(visible = state.typingEffectEnabled) { - Column(modifier = Modifier.padding(top = 12.dp)) { - Text( - text = - stringResource( - R.string.settings_item_typing_delay, - state.typingEffectDelayMs, - ), - style = MaterialTheme.typography.bodyMedium, - ) - Spacer(modifier = Modifier.height(4.dp)) - Slider( - value = state.typingEffectDelayMs.toFloat(), - onValueChange = { viewModel.onTypingEffectDelayMsChange(it.toInt()) }, - valueRange = 10f..100f, - steps = 8, // 10, 20, 30, 40, 50, 60, 70, 80, 90, 100 - modifier = Modifier.fillMaxWidth(), - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = stringResource(R.string.settings_delay_10ms), - style = - MaterialTheme.typography.labelSmall.copy( - color = MaterialTheme.colorScheme.onSurfaceVariant, - ), - ) - Text( - text = stringResource(R.string.settings_delay_100ms), - style = - MaterialTheme.typography.labelSmall.copy( - color = MaterialTheme.colorScheme.onSurfaceVariant, - ), - ) - } - } - } - } - - // Navigation Bar section - SectionCard(title = stringResource(R.string.settings_sec_nav_bar)) { - Text( - text = stringResource(R.string.settings_item_bottom_nav_display_mode), - style = MaterialTheme.typography.bodyLarge, - ) - Spacer(modifier = Modifier.height(8.dp)) - SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { - BottomNavDisplayMode.entries.forEachIndexed { index, mode -> - SegmentedButton( - selected = state.bottomNavDisplayMode == mode, - onClick = { viewModel.onBottomNavDisplayModeChange(mode) }, - shape = - SegmentedButtonDefaults.itemShape( - index = index, - count = BottomNavDisplayMode.entries.size, - ), - ) { - Text( - when (mode) { - BottomNavDisplayMode.ICON_AND_TEXT -> { - stringResource( - R.string.settings_display_mode_icon_and_text, - ) - } - - BottomNavDisplayMode.ICON_ONLY -> { - stringResource( - R.string.settings_display_mode_icon_only, - ) - } - - BottomNavDisplayMode.TEXT_ONLY -> { - stringResource( - R.string.settings_display_mode_text_only, - ) - } - }, - ) - } - } - } - - Spacer(modifier = Modifier.height(16.dp)) - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - Spacer(modifier = Modifier.height(16.dp)) - - Text( - text = stringResource(R.string.settings_item_nav_items, state.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 - val label = if (labelRes != null) stringResource(labelRes) else name - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = label, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f), - ) - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - IconButton( - onClick = { - val list = state.selectedNavItems.toMutableList() - val temp = list[index] - list[index] = list[index - 1] - list[index - 1] = temp - viewModel.reorderNavItems(list) - }, - enabled = index > 0, - ) { - Icon( - Icons.Filled.KeyboardArrowUp, - contentDescription = stringResource(R.string.content_desc_move_up), - ) - } - IconButton( - onClick = { - val list = state.selectedNavItems.toMutableList() - val temp = list[index] - list[index] = list[index + 1] - list[index + 1] = temp - viewModel.reorderNavItems(list) - }, - enabled = index < state.selectedNavItems.lastIndex, - ) { - Icon( - Icons.Filled.KeyboardArrowDown, - contentDescription = stringResource(R.string.content_desc_move_down), - ) - } - IconButton(onClick = { viewModel.removeNavItem(name) }) { - Icon( - Icons.Filled.Close, - contentDescription = - stringResource( - R.string.content_desc_remove_item, - label, - ), - ) - } - } - } - } - - if (state.selectedNavItems.size < 5) { - val available = - viewModel.availableNavItems.filter { it.first !in state.selectedNavItems } - if (available.isNotEmpty()) { - var expanded by remember { mutableStateOf(false) } - Box { - OutlinedButton(onClick = { expanded = true }) { - Icon(Icons.Filled.Add, contentDescription = null) - Spacer(Modifier.size(4.dp)) - Text(stringResource(R.string.settings_action_add_item)) - } - DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - available.forEach { (name, labelRes) -> - DropdownMenuItem( - text = { Text(stringResource(labelRes)) }, - onClick = { - viewModel.addNavItem(name) - expanded = false - }, - ) - } - } - } - } - } else { - Text( - text = stringResource(R.string.settings_nav_max_reached), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } + 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 ───────────────────────────────────────── @@ -886,3 +304,741 @@ private fun SectionCard( } } } + +@Composable +private fun ConnectionSection( + state: SettingsUiState, + viewModel: SettingsViewModel, + passwordVisible: Boolean, + onPasswordVisibilityToggle: () -> Unit, +) { + SectionCard(title = stringResource(R.string.settings_sec_connection)) { + if (state.profiles.isNotEmpty()) { + var profilesExpanded by remember { mutableStateOf(false) } + Text( + text = stringResource(R.string.settings_item_profile), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Box(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + OutlinedButton( + onClick = { profilesExpanded = true }, + modifier = Modifier.fillMaxWidth(), + ) { + val activeProfile = + state.profiles.firstOrNull { it.id == state.selectedProfileId } + Text( + text = + activeProfile?.name ?: stringResource( + R.string.settings_profile_default, + ), + ) + } + DropdownMenu( + expanded = profilesExpanded, + onDismissRequest = { profilesExpanded = false }, + modifier = Modifier.fillMaxWidth(0.85f), + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.settings_profile_none)) }, + onClick = { + viewModel.selectProfile(null) + profilesExpanded = false + }, + ) + state.profiles.forEach { profile -> + DropdownMenuItem( + text = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(profile.name) + IconButton( + onClick = { + viewModel.deleteProfile(profile.id) + profilesExpanded = false + }, + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = + stringResource( + R.string.content_desc_delete_profile, + ), + tint = MaterialTheme.colorScheme.error, + ) + } + } + }, + onClick = { + viewModel.selectProfile(profile.id) + profilesExpanded = false + }, + ) + } + } + } + + if (state.selectedProfileId != null) { + OutlinedTextField( + value = state.renameProfileName, + onValueChange = viewModel::onRenameProfileNameChange, + label = { Text(stringResource(R.string.settings_action_rename_profile)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + trailingIcon = { + Button( + onClick = viewModel::renameProfile, + modifier = Modifier.padding(end = 4.dp), + ) { + Text(stringResource(R.string.action_rename)) + } + }, + ) + Spacer(modifier = Modifier.height(12.dp)) + } + } + + OutlinedTextField( + value = state.host, + onValueChange = viewModel::onHostChange, + label = { Text(stringResource(R.string.settings_field_host)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + colors = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + cursorColor = MaterialTheme.colorScheme.primary, + ), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = state.port, + onValueChange = viewModel::onPortChange, + label = { Text(stringResource(R.string.settings_field_port)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + colors = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + cursorColor = MaterialTheme.colorScheme.primary, + ), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = state.token, + onValueChange = viewModel::onTokenChange, + label = { Text(stringResource(R.string.settings_field_token)) }, + singleLine = true, + visualTransformation = + if (passwordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + trailingIcon = { + IconButton(onClick = onPasswordVisibilityToggle) { + Icon( + imageVector = + if (passwordVisible) { + Icons.Filled.Visibility + } else { + Icons.Filled.VisibilityOff + }, + contentDescription = + if (passwordVisible) { + stringResource(R.string.content_desc_hide_token) + } else { + stringResource(R.string.content_desc_show_token) + }, + ) + } + }, + modifier = Modifier.fillMaxWidth(), + colors = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + cursorColor = MaterialTheme.colorScheme.primary, + ), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // B7 (Jun 18 2026): one-tap escape hatch for when the stored + // token is corrupted / rejected. Clears the token field and + // persists the empty value immediately so a stress-free + // re-pair is possible without manually selecting-and-deleting + // the masked password field character by character. + OutlinedButton( + onClick = { + viewModel.onTokenChange("") + viewModel.save() + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.settings_action_clear_token)) + } + } +} + +@Composable +private fun TestResultCard(testResult: String?) { + AnimatedVisibility( + visible = testResult != null, + enter = fadeIn(), + exit = fadeOut(), + ) { + testResult?.let { result -> + Card( + colors = + CardDefaults.cardColors( + containerColor = + if (result.startsWith("✅")) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.errorContainer + }, + ), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = result, + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } +} + +@Composable +private fun SaveIndicator(isSaved: Boolean) { + AnimatedVisibility( + visible = isSaved, + enter = fadeIn(), + exit = fadeOut(), + ) { + Text( + text = stringResource(R.string.settings_save_success), + style = + MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.primary, + ), + ) + } +} + +@Composable +private fun SettingsActionButtons( + isTesting: Boolean, + onTest: () -> Unit, + onSave: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedButton( + onClick = onTest, + enabled = !isTesting, + modifier = Modifier.weight(1f), + ) { + if (isTesting) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + ) + } else { + Text(stringResource(R.string.settings_action_test_connection)) + } + } + + Button( + onClick = onSave, + modifier = Modifier.weight(1f), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text(stringResource(R.string.action_save)) + } + } +} + +@Composable +private fun BehaviorSection( + autoReconnect: Boolean, + onAutoReconnectChange: (Boolean) -> Unit, +) { + SectionCard(title = stringResource(R.string.settings_sec_behavior)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text( + text = stringResource(R.string.settings_item_auto_reconnect), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.settings_desc_auto_reconnect), + style = + MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + } + Switch( + checked = autoReconnect, + onCheckedChange = onAutoReconnectChange, + colors = + SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.primary, + checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, + ), + ) + } + } +} + +@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), + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(modifier = Modifier.height(8.dp)) + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + ThemePreference.entries.forEachIndexed { index, pref -> + SegmentedButton( + selected = themePreference == pref, + onClick = { onThemeChange(pref) }, + shape = + SegmentedButtonDefaults.itemShape( + index = index, + count = ThemePreference.entries.size, + ), + ) { + Text( + when (pref) { + ThemePreference.SYSTEM -> stringResource(R.string.theme_system) + ThemePreference.LIGHT -> stringResource(R.string.theme_light) + ThemePreference.DARK -> stringResource(R.string.theme_dark) + }, + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_item_use_dynamic_colors), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.settings_desc_use_dynamic_colors), + style = + MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + } + Switch( + checked = useDynamicColors, + onCheckedChange = onUseDynamicColorsChange, + colors = + SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.primary, + checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, + ), + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.settings_item_theme_preset), + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(modifier = Modifier.height(8.dp)) + + var presetsExpanded by remember { mutableStateOf(false) } + Box(modifier = Modifier.fillMaxWidth()) { + OutlinedButton( + onClick = { presetsExpanded = true }, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + 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) + ThemePreset.CATPPUCCIN -> stringResource(R.string.theme_preset_catppuccin) + ThemePreset.AMOLED -> stringResource(R.string.theme_preset_amoled) + }, + ) + } + DropdownMenu( + expanded = presetsExpanded, + onDismissRequest = { presetsExpanded = false }, + modifier = Modifier.fillMaxWidth(0.85f), + ) { + ThemePreset.entries.forEach { preset -> + DropdownMenuItem( + text = { + Text( + when (preset) { + ThemePreset.DEFAULT -> + stringResource( + R.string.theme_preset_default, + ) + ThemePreset.MONOCHROME -> + stringResource( + R.string.theme_preset_monochrome, + ) + ThemePreset.GRUVBOX -> + stringResource( + R.string.theme_preset_gruvbox, + ) + ThemePreset.CATPPUCCIN -> + stringResource( + R.string.theme_preset_catppuccin, + ) + ThemePreset.AMOLED -> + stringResource( + R.string.theme_preset_amoled, + ) + }, + ) + }, + onClick = { + onThemePresetChange(preset) + presetsExpanded = false + }, + ) + } + } + } + } +} + +@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(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text( + text = stringResource(R.string.settings_item_typing_effect), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.settings_desc_typing_effect), + style = + MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + } + Switch( + checked = typingEffectEnabled, + onCheckedChange = onTypingEffectEnabledChange, + colors = + SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.primary, + checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, + ), + ) + } + + // Delay slider — only visible when effect is enabled + AnimatedVisibility(visible = typingEffectEnabled) { + Column(modifier = Modifier.padding(top = 12.dp)) { + Text( + text = + stringResource( + R.string.settings_item_typing_delay, + typingEffectDelayMs, + ), + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(modifier = Modifier.height(4.dp)) + Slider( + 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(), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.settings_delay_10ms), + style = + MaterialTheme.typography.labelSmall.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + Text( + text = stringResource(R.string.settings_delay_100ms), + style = + MaterialTheme.typography.labelSmall.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + } + } + } + } +} + +@Composable +private fun NavBarSection( + bottomNavDisplayMode: BottomNavDisplayMode, + onBottomNavDisplayModeChange: (BottomNavDisplayMode) -> Unit, + selectedNavItems: List, + availableNavItems: List>, + onReorderNavItems: (List) -> 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), + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(modifier = Modifier.height(8.dp)) + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + BottomNavDisplayMode.entries.forEachIndexed { index, mode -> + SegmentedButton( + selected = bottomNavDisplayMode == mode, + onClick = { onBottomNavDisplayModeChange(mode) }, + shape = + SegmentedButtonDefaults.itemShape( + index = index, + count = BottomNavDisplayMode.entries.size, + ), + ) { + Text( + when (mode) { + BottomNavDisplayMode.ICON_AND_TEXT -> { + stringResource( + R.string.settings_display_mode_icon_and_text, + ) + } + + BottomNavDisplayMode.ICON_ONLY -> { + stringResource( + R.string.settings_display_mode_icon_only, + ) + } + + BottomNavDisplayMode.TEXT_ONLY -> { + stringResource( + R.string.settings_display_mode_text_only, + ) + } + }, + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.settings_item_nav_items, selectedNavItems.size), + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(modifier = Modifier.height(8.dp)) + + 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), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + IconButton( + onClick = { + val list = selectedNavItems.toMutableList() + val temp = list[index] + list[index] = list[index - 1] + list[index - 1] = temp + onReorderNavItems(list) + }, + enabled = index > 0, + ) { + Icon( + Icons.Filled.KeyboardArrowUp, + contentDescription = stringResource(R.string.content_desc_move_up), + ) + } + IconButton( + onClick = { + val list = selectedNavItems.toMutableList() + val temp = list[index] + list[index] = list[index + 1] + list[index + 1] = temp + onReorderNavItems(list) + }, + enabled = index < selectedNavItems.lastIndex, + ) { + Icon( + Icons.Filled.KeyboardArrowDown, + contentDescription = stringResource(R.string.content_desc_move_down), + ) + } + IconButton(onClick = { onRemoveNavItem(name) }) { + Icon( + Icons.Filled.Close, + contentDescription = + stringResource( + R.string.content_desc_remove_item, + label, + ), + ) + } + } + } + } + + if (selectedNavItems.size < 5) { + val available = + availableNavItems.filter { it.first !in selectedNavItems } + if (available.isNotEmpty()) { + var expanded by remember { mutableStateOf(false) } + Box { + OutlinedButton(onClick = { expanded = true }) { + Icon(Icons.Filled.Add, contentDescription = null) + Spacer(Modifier.size(4.dp)) + Text(stringResource(R.string.settings_action_add_item)) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + available.forEach { (name, labelRes) -> + DropdownMenuItem( + text = { Text(stringResource(labelRes)) }, + onClick = { + onAddNavItem(name) + expanded = false + }, + ) + } + } + } + } + } else { + Text( + text = stringResource(R.string.settings_nav_max_reached), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun AboutSection(onLogout: () -> Unit) { + 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 = onLogout, + modifier = Modifier.fillMaxWidth(), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringResource(R.string.settings_logout)) + } + } +}