From 3c5fcf504c3030bb32b30190658fd2ab00a28695 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sat, 20 Jun 2026 17:09:42 +0300 Subject: [PATCH] feat(#154): add word-by-word typing effect toggle and speed slider in settings (#165) * feat(#154): add word-by-word typing effect toggle and speed slider in settings * fix(settings): import Slider to fix build failure --------- Co-authored-by: M57 --- .../hermescontrol/data/local/AuthManager.kt | 20 ++++++ .../m57/hermescontrol/ui/chat/ChatScreen.kt | 69 ++++++++++++++++-- .../ui/settings/SettingsScreen.kt | 70 +++++++++++++++++++ .../ui/settings/SettingsViewModel.kt | 14 ++++ 4 files changed, 167 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index 7f024aee..6a6265d9 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -24,10 +24,14 @@ object AuthManager { private const val KEY_AUTO_RECONNECT = "auto_reconnect" private const val KEY_THEME_PREFERENCE = "theme_preference" private const val KEY_BOTTOM_NAV_ITEMS = "bottom_nav_items" + private const val KEY_TYPING_EFFECT_ENABLED = "typing_effect_enabled" + private const val KEY_TYPING_EFFECT_DELAY_MS = "typing_effect_delay_ms" private const val DEFAULT_HOST = "127.0.0.1" private const val DEFAULT_PORT = 9119 private const val DEFAULT_AUTO_RECONNECT = true + private const val DEFAULT_TYPING_EFFECT_ENABLED = false + private const val DEFAULT_TYPING_EFFECT_DELAY_MS = 30 @Volatile private var prefs: SharedPreferences? = null @@ -144,4 +148,20 @@ object AuthManager { requirePrefs().edit().putString(KEY_BOTTOM_NAV_ITEMS, items.joinToString(",")).apply() _bottomNavItemsFlow.value = items } + + // ── Typing Effect ─────────────────────────────────────────────────── + + fun isTypingEffectEnabled(): Boolean = + requirePrefs().getBoolean(KEY_TYPING_EFFECT_ENABLED, DEFAULT_TYPING_EFFECT_ENABLED) + + fun setTypingEffectEnabled(enabled: Boolean) { + requirePrefs().edit().putBoolean(KEY_TYPING_EFFECT_ENABLED, enabled).apply() + } + + fun getTypingEffectDelayMs(): Int = + requirePrefs().getInt(KEY_TYPING_EFFECT_DELAY_MS, DEFAULT_TYPING_EFFECT_DELAY_MS) + + fun setTypingEffectDelayMs(delayMs: Int) { + requirePrefs().edit().putInt(KEY_TYPING_EFFECT_DELAY_MS, delayMs).apply() + } } 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 a915324c..9c7b13f1 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 @@ -78,6 +78,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -101,11 +102,13 @@ import androidx.core.content.ContextCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.notification.NotificationHelper import com.m57.hermescontrol.theme.StatusRed import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.HermesScaffold +import kotlinx.coroutines.delay import kotlinx.coroutines.launch @Composable @@ -482,12 +485,23 @@ fun ChatScreen( // Streaming message — rendered separately for O(1) updates state.streamingMessage?.let { streaming -> item(key = "streaming-${streaming.id}") { - ChatBubble( - message = streaming, - isDarkTheme = isDark, - searchQuery = "", - isCurrentMatch = false, - ) + val typingEnabled = AuthManager.isTypingEffectEnabled() + val typingDelayMs = AuthManager.getTypingEffectDelayMs() + + if (typingEnabled && streaming.isStreaming) { + StreamingBubbleWithTypingEffect( + streaming = streaming, + typingDelayMs = typingDelayMs, + isDark = isDark, + ) + } else { + ChatBubble( + message = streaming, + isDarkTheme = isDark, + searchQuery = "", + isCurrentMatch = false, + ) + } } } @@ -1039,3 +1053,46 @@ private fun SearchBarRow( } } } + +/** + * Wraps a streaming [ChatBubble] with a word-by-word typing reveal effect. + * Shows words one at a time at [typingDelayMs] intervals while the message is + * still streaming. When streaming completes the full text is shown immediately. + * The underlying [ChatMessage.content] in state is never modified — this is a + * display-only transformation. + */ +@Composable +private fun StreamingBubbleWithTypingEffect( + streaming: ChatMessage, + typingDelayMs: Int, + isDark: Boolean, +) { + var visibleWordCount by remember { mutableIntStateOf(0) } + + // Timer that ticks at the configured delay, incrementing the visible word + // count each tick. Stops ticking when streaming ends, then shows all words. + LaunchedEffect(Unit) { + while (streaming.isStreaming) { + delay(typingDelayMs.toLong()) + visibleWordCount++ + } + visibleWordCount = Int.MAX_VALUE + } + + // Derive display text from the latest full content at each recomposition + val words = streaming.content.split(" ") + val visibleCount = + if (visibleWordCount >= Int.MAX_VALUE / 2) { + words.size + } else { + visibleWordCount.coerceIn(0, words.size) + } + val displayText = words.take(visibleCount.coerceAtLeast(1)).joinToString(" ") + + ChatBubble( + message = streaming.copy(content = displayText), + isDarkTheme = isDark, + searchQuery = "", + isCurrentMatch = false, + ) +} 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 b45c323d..1e820e97 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 @@ -42,6 +42,7 @@ import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Slider import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text @@ -236,6 +237,75 @@ fun SettingsScreen( } } + // Chat section + SectionCard(title = "Chat") { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text( + text = "Typing Effect", + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = "Animate new messages word by word", + 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 = "Delay: ${state.typingEffectDelayMs} ms", + 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 = "10 ms", + style = + MaterialTheme.typography.labelSmall.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + Text( + text = "100 ms", + style = + MaterialTheme.typography.labelSmall.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + } + } + } + } + // Navigation Bar section SectionCard(title = "Navigation Bar") { Text( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt index 65c3a04f..028f0b7d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt @@ -25,6 +25,8 @@ data class SettingsUiState( val testResult: String? = null, val isSaved: Boolean = false, val selectedNavItems: List = emptyList(), + val typingEffectEnabled: Boolean = false, + val typingEffectDelayMs: Int = 30, ) class SettingsViewModel : ViewModel() { @@ -47,6 +49,8 @@ class SettingsViewModel : ViewModel() { // was missing — always defaulted to ThemePreference.SYSTEM. themePreference = AuthManager.getThemePreference(), selectedNavItems = AuthManager.getBottomNavItems(), + typingEffectEnabled = AuthManager.isTypingEffectEnabled(), + typingEffectDelayMs = AuthManager.getTypingEffectDelayMs(), ) } } @@ -71,6 +75,14 @@ class SettingsViewModel : ViewModel() { _uiState.update { it.copy(themePreference = theme, isSaved = false) } } + fun onTypingEffectEnabledChange(enabled: Boolean) { + _uiState.update { it.copy(typingEffectEnabled = enabled, isSaved = false) } + } + + fun onTypingEffectDelayMsChange(delayMs: Int) { + _uiState.update { it.copy(typingEffectDelayMs = delayMs, isSaved = false) } + } + /** All screens available for bottom-nav selection (name → display label). */ val availableNavItems: List> = listOf( @@ -128,6 +140,8 @@ class SettingsViewModel : ViewModel() { // B6 (Jun 18 2026, kanban t_86e9be9b): persist theme choice so it // survives a cold start (was previously dropped on save()). AuthManager.setThemePreference(state.themePreference) + AuthManager.setTypingEffectEnabled(state.typingEffectEnabled) + AuthManager.setTypingEffectDelayMs(state.typingEffectDelayMs) ApiClient.rebuild() _uiState.update { it.copy(isSaved = true, testResult = null) }