* 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 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
8c9c11df23
commit
3c5fcf504c
4 files changed
+167
-6
No files matched your search
@@ -24,10 +24,14 @@ object AuthManager {
|
|||||||
private const val KEY_AUTO_RECONNECT = "auto_reconnect"
|
private const val KEY_AUTO_RECONNECT = "auto_reconnect"
|
||||||
private const val KEY_THEME_PREFERENCE = "theme_preference"
|
private const val KEY_THEME_PREFERENCE = "theme_preference"
|
||||||
private const val KEY_BOTTOM_NAV_ITEMS = "bottom_nav_items"
|
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_HOST = "127.0.0.1"
|
||||||
private const val DEFAULT_PORT = 9119
|
private const val DEFAULT_PORT = 9119
|
||||||
private const val DEFAULT_AUTO_RECONNECT = true
|
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
|
@Volatile
|
||||||
private var prefs: SharedPreferences? = null
|
private var prefs: SharedPreferences? = null
|
||||||
@@ -144,4 +148,20 @@ object AuthManager {
|
|||||||
requirePrefs().edit().putString(KEY_BOTTOM_NAV_ITEMS, items.joinToString(",")).apply()
|
requirePrefs().edit().putString(KEY_BOTTOM_NAV_ITEMS, items.joinToString(",")).apply()
|
||||||
_bottomNavItemsFlow.value = items
|
_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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -78,6 +78,7 @@ import androidx.compose.runtime.DisposableEffect
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
@@ -101,11 +102,13 @@ import androidx.core.content.ContextCompat
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||||
import com.m57.hermescontrol.notification.NotificationHelper
|
import com.m57.hermescontrol.notification.NotificationHelper
|
||||||
import com.m57.hermescontrol.theme.StatusRed
|
import com.m57.hermescontrol.theme.StatusRed
|
||||||
import com.m57.hermescontrol.ui.common.EmptyState
|
import com.m57.hermescontrol.ui.common.EmptyState
|
||||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -482,12 +485,23 @@ fun ChatScreen(
|
|||||||
// Streaming message — rendered separately for O(1) updates
|
// Streaming message — rendered separately for O(1) updates
|
||||||
state.streamingMessage?.let { streaming ->
|
state.streamingMessage?.let { streaming ->
|
||||||
item(key = "streaming-${streaming.id}") {
|
item(key = "streaming-${streaming.id}") {
|
||||||
ChatBubble(
|
val typingEnabled = AuthManager.isTypingEffectEnabled()
|
||||||
message = streaming,
|
val typingDelayMs = AuthManager.getTypingEffectDelayMs()
|
||||||
isDarkTheme = isDark,
|
|
||||||
searchQuery = "",
|
if (typingEnabled && streaming.isStreaming) {
|
||||||
isCurrentMatch = false,
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ import androidx.compose.material3.OutlinedTextFieldDefaults
|
|||||||
import androidx.compose.material3.SegmentedButton
|
import androidx.compose.material3.SegmentedButton
|
||||||
import androidx.compose.material3.SegmentedButtonDefaults
|
import androidx.compose.material3.SegmentedButtonDefaults
|
||||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.SwitchDefaults
|
import androidx.compose.material3.SwitchDefaults
|
||||||
import androidx.compose.material3.Text
|
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
|
// Navigation Bar section
|
||||||
SectionCard(title = "Navigation Bar") {
|
SectionCard(title = "Navigation Bar") {
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ data class SettingsUiState(
|
|||||||
val testResult: String? = null,
|
val testResult: String? = null,
|
||||||
val isSaved: Boolean = false,
|
val isSaved: Boolean = false,
|
||||||
val selectedNavItems: List<String> = emptyList(),
|
val selectedNavItems: List<String> = emptyList(),
|
||||||
|
val typingEffectEnabled: Boolean = false,
|
||||||
|
val typingEffectDelayMs: Int = 30,
|
||||||
)
|
)
|
||||||
|
|
||||||
class SettingsViewModel : ViewModel() {
|
class SettingsViewModel : ViewModel() {
|
||||||
@@ -47,6 +49,8 @@ class SettingsViewModel : ViewModel() {
|
|||||||
// was missing — always defaulted to ThemePreference.SYSTEM.
|
// was missing — always defaulted to ThemePreference.SYSTEM.
|
||||||
themePreference = AuthManager.getThemePreference(),
|
themePreference = AuthManager.getThemePreference(),
|
||||||
selectedNavItems = AuthManager.getBottomNavItems(),
|
selectedNavItems = AuthManager.getBottomNavItems(),
|
||||||
|
typingEffectEnabled = AuthManager.isTypingEffectEnabled(),
|
||||||
|
typingEffectDelayMs = AuthManager.getTypingEffectDelayMs(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,6 +75,14 @@ class SettingsViewModel : ViewModel() {
|
|||||||
_uiState.update { it.copy(themePreference = theme, isSaved = false) }
|
_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). */
|
/** All screens available for bottom-nav selection (name → display label). */
|
||||||
val availableNavItems: List<Pair<String, String>> =
|
val availableNavItems: List<Pair<String, String>> =
|
||||||
listOf(
|
listOf(
|
||||||
@@ -128,6 +140,8 @@ class SettingsViewModel : ViewModel() {
|
|||||||
// B6 (Jun 18 2026, kanban t_86e9be9b): persist theme choice so it
|
// B6 (Jun 18 2026, kanban t_86e9be9b): persist theme choice so it
|
||||||
// survives a cold start (was previously dropped on save()).
|
// survives a cold start (was previously dropped on save()).
|
||||||
AuthManager.setThemePreference(state.themePreference)
|
AuthManager.setThemePreference(state.themePreference)
|
||||||
|
AuthManager.setTypingEffectEnabled(state.typingEffectEnabled)
|
||||||
|
AuthManager.setTypingEffectDelayMs(state.typingEffectDelayMs)
|
||||||
ApiClient.rebuild()
|
ApiClient.rebuild()
|
||||||
|
|
||||||
_uiState.update { it.copy(isSaved = true, testResult = null) }
|
_uiState.update { it.copy(isSaved = true, testResult = null) }
|
||||||
|
|||||||
Reference in new issue
Block a user