feat(settings): in-app chat font size setting (#1004) (#1005)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57
2026-08-29 11:36:56 +03:00
committed by GitHub
co-authored by M57
parent db3f8c736e
commit 5bc6e5f855
14 changed files with 461 additions and 182 deletions
@@ -54,10 +54,12 @@ class MainActivity : ComponentActivity() {
val themePreference by AuthManager.themePreferenceFlow.collectAsState() val themePreference by AuthManager.themePreferenceFlow.collectAsState()
val useDynamicColors by AuthManager.useDynamicColorsFlow.collectAsState() val useDynamicColors by AuthManager.useDynamicColorsFlow.collectAsState()
val themePreset by AuthManager.themePresetFlow.collectAsState() val themePreset by AuthManager.themePresetFlow.collectAsState()
val chatFontScale by AuthManager.chatFontScaleFlow.collectAsState()
HermesControlTheme( HermesControlTheme(
themePreference = themePreference, themePreference = themePreference,
useDynamicColors = useDynamicColors, useDynamicColors = useDynamicColors,
themePreset = themePreset, themePreset = themePreset,
chatFontScale = chatFontScale,
) { ) {
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -21,6 +21,7 @@ data class ServerStoreState(
val wsAuthParam: String = "token", val wsAuthParam: String = "token",
val typingEffectEnabled: Boolean = true, val typingEffectEnabled: Boolean = true,
val typingEffectDelayMs: Int = 30, val typingEffectDelayMs: Int = 30,
val chatFontScale: Float = 1.0f,
// App display language. "system" = follow device locale; otherwise a BCP-47 // App display language. "system" = follow device locale; otherwise a BCP-47
// language code such as "en" or "ko". Applied via ContextWrapper in MainActivity. // language code such as "en" or "ko". Applied via ContextWrapper in MainActivity.
val appLanguage: String = "system", val appLanguage: String = "system",
@@ -74,6 +74,9 @@ object AuthManager {
private val _themePresetFlow = MutableStateFlow<ThemePreset>(ThemePreset.DEFAULT) private val _themePresetFlow = MutableStateFlow<ThemePreset>(ThemePreset.DEFAULT)
val themePresetFlow: StateFlow<ThemePreset> = _themePresetFlow.asStateFlow() val themePresetFlow: StateFlow<ThemePreset> = _themePresetFlow.asStateFlow()
private val _chatFontScaleFlow = MutableStateFlow<Float>(1.0f)
val chatFontScaleFlow: StateFlow<Float> = _chatFontScaleFlow.asStateFlow()
private val _tokenFlow = MutableStateFlow<String?>(null) private val _tokenFlow = MutableStateFlow<String?>(null)
val tokenFlow: StateFlow<String?> = _tokenFlow.asStateFlow() val tokenFlow: StateFlow<String?> = _tokenFlow.asStateFlow()
@@ -180,6 +183,7 @@ object AuthManager {
_themePreferenceFlow.value = state.themePreference _themePreferenceFlow.value = state.themePreference
_useDynamicColorsFlow.value = state.useDynamicColors _useDynamicColorsFlow.value = state.useDynamicColors
_themePresetFlow.value = state.themePreset _themePresetFlow.value = state.themePreset
_chatFontScaleFlow.value = state.chatFontScale
// B7 (Jul 08 2026, kanban t_470): keep cookie scope aligned with active profile. // B7 (Jul 08 2026, kanban t_470): keep cookie scope aligned with active profile.
appScope?.launch { syncCookieStoreForProfile(state.selectedProfileId) } appScope?.launch { syncCookieStoreForProfile(state.selectedProfileId) }
} }
@@ -640,6 +644,15 @@ object AuthManager {
serverStore.update { it.copy(typingEffectDelayMs = delayMs) } serverStore.update { it.copy(typingEffectDelayMs = delayMs) }
} }
// ── Chat Font Scale (issue #1004) ───────────────────────────────────
fun getChatFontScale(): Float = serverStore.getLatestState().chatFontScale
fun setChatFontScale(scale: Float) {
serverStore.update { it.copy(chatFontScale = scale) }
_chatFontScaleFlow.value = scale
}
// ── In-app update check (issue #867) ───────────────────────────────── // ── In-app update check (issue #867) ─────────────────────────────────
/** App version the silent update check last completed for (null = never). */ /** App version the silent update check last completed for (null = never). */
@@ -26,6 +26,7 @@ enum class ThemePreset { DEFAULT, MONOCHROME, GRUVBOX, CATPPUCCIN, AMOLED, NORD
val LocalThemePreference = compositionLocalOf { ThemePreference.SYSTEM } val LocalThemePreference = compositionLocalOf { ThemePreference.SYSTEM }
val LocalThemePreset = compositionLocalOf { ThemePreset.DEFAULT } val LocalThemePreset = compositionLocalOf { ThemePreset.DEFAULT }
val LocalChatFontScale = compositionLocalOf { 1.0f }
/** /**
* The 6 preset themes — one file each, all built from the same * The 6 preset themes — one file each, all built from the same
@@ -75,6 +76,7 @@ fun HermesControlTheme(
themePreference: ThemePreference = LocalThemePreference.current, themePreference: ThemePreference = LocalThemePreference.current,
useDynamicColors: Boolean = true, useDynamicColors: Boolean = true,
themePreset: ThemePreset = ThemePreset.DEFAULT, themePreset: ThemePreset = ThemePreset.DEFAULT,
chatFontScale: Float = LocalChatFontScale.current,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val darkTheme = val darkTheme =
@@ -103,6 +105,7 @@ fun HermesControlTheme(
CompositionLocalProvider( CompositionLocalProvider(
LocalThemePreference provides themePreference, LocalThemePreference provides themePreference,
LocalThemePreset provides themePreset, LocalThemePreset provides themePreset,
LocalChatFontScale provides chatFontScale,
LocalHermesStatusColors provides statusColors, LocalHermesStatusColors provides statusColors,
LocalSpacing provides SpacingDefaults, LocalSpacing provides SpacingDefaults,
LocalMotion provides MotionDefaults, LocalMotion provides MotionDefaults,
@@ -32,6 +32,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -43,14 +44,17 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
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.theme.LocalChatFontScale
import com.m57.hermescontrol.ui.chat.MarkdownText import com.m57.hermescontrol.ui.chat.MarkdownText
import com.m57.hermescontrol.ui.common.BotAvatar import com.m57.hermescontrol.ui.common.BotAvatar
import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.EmptyState
@@ -141,38 +145,50 @@ fun GroupChatScreen(
} }
else -> { else -> {
LazyColumn( val currentDensity = LocalDensity.current
state = listState, val chatFontScale = LocalChatFontScale.current
modifier = val chatDensity =
Modifier remember(currentDensity, chatFontScale) {
.weight(1f) Density(
.fillMaxWidth(), density = currentDensity.density,
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), fontScale = currentDensity.fontScale * chatFontScale,
verticalArrangement = Arrangement.spacedBy(12.dp), )
) {
items(
items = state.messages,
key = { it.id },
) { message ->
GroupMessageCard(message = message)
} }
state.activeSpeaker?.let { speaker -> CompositionLocalProvider(LocalDensity provides chatDensity) {
item(key = "active_speaker") { LazyColumn(
Row( state = listState,
modifier = Modifier.padding(start = 8.dp, top = 4.dp), modifier =
verticalAlignment = Alignment.CenterVertically, Modifier
) { .weight(1f)
CircularProgressIndicator( .fillMaxWidth(),
modifier = Modifier.size(14.dp), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
strokeWidth = 2.dp, verticalArrangement = Arrangement.spacedBy(12.dp),
) ) {
Spacer(modifier = Modifier.width(8.dp)) items(
Text( items = state.messages,
text = stringResource(R.string.group_chat_thinking, speaker), key = { it.id },
style = MaterialTheme.typography.bodySmall, ) { message ->
color = MaterialTheme.colorScheme.primary, GroupMessageCard(message = message)
) }
state.activeSpeaker?.let { speaker ->
item(key = "active_speaker") {
Row(
modifier = Modifier.padding(start = 8.dp, top = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.group_chat_thinking, speaker),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
}
} }
} }
} }
@@ -10,13 +10,17 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.LocalChatFontScale
import com.m57.hermescontrol.ui.chat.ChatBubble import com.m57.hermescontrol.ui.chat.ChatBubble
import com.m57.hermescontrol.ui.chat.ChatMessage import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.ChatSearchState import com.m57.hermescontrol.ui.chat.ChatSearchState
@@ -116,179 +120,191 @@ fun FullBleedChatList(
} }
} }
LazyColumn( val currentDensity = LocalDensity.current
state = listState, val chatFontScale = LocalChatFontScale.current
modifier = Modifier.fillMaxSize(), val chatDensity =
contentPadding = PaddingValues(vertical = 8.dp), remember(currentDensity, chatFontScale) {
) { Density(
if (isLoadingOlder) { density = currentDensity.density,
item(key = "loading-older") { fontScale = currentDensity.fontScale * chatFontScale,
Box( )
modifier = Modifier.fillMaxWidth().padding(12.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
}
} }
var entryIndex = 0 CompositionLocalProvider(LocalDensity provides chatDensity) {
turns.forEach { turn -> LazyColumn(
when (turn) { state = listState,
is ChatTurn.User -> { modifier = Modifier.fillMaxSize(),
// Eager captures: item lambda reads these at contentPadding = PaddingValues(vertical = 8.dp),
// composition time (lazy), so capture now. ) {
val userMessage = turn.message if (isLoadingOlder) {
val milestone = toolMilestones[entryIndex] item(key = "loading-older") {
item(key = "user-${userMessage.id}") { Box(
Column(modifier = Modifier.padding(bottom = 12.dp)) { modifier = Modifier.fillMaxWidth().padding(12.dp),
renderChatBubble( contentAlignment = Alignment.Center,
message = userMessage, ) {
searchQuery = if (searchState.isActive) searchState.query else "", CircularProgressIndicator(modifier = Modifier.size(24.dp))
isCurrentMatch =
searchState.currentMatchId != null &&
searchState.currentMatchId == userMessage.id,
onOpenAttachment = viewModel::openAttachment,
onSaveAttachment = onSaveAttachment,
savingAttachmentPath = savingAttachmentPath,
openingAttachmentPath = openingAttachmentPath,
onImageClick = onImageClick,
)
milestone?.let { count ->
ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn)
}
}
} }
entryIndex++
} }
}
is ChatTurn.Agent -> { var entryIndex = 0
var firstProseSeen = false turns.forEach { turn ->
// Reasoning hoist: the turn's reasoning renders at the when (turn) {
// TOP of the turn — above tool rows — so thinking is ChatTurn.User -> {
// leads, then the tool work, then the answer. The // Eager captures: item lambda reads these at
// matching prose entry renders without its own card. // composition time (lazy), so capture now.
val turnReasoning = val userMessage = turn.message
turn.entries val milestone = toolMilestones[entryIndex]
.filterIsInstance<AgentEntry.Prose>() item(key = "user-${userMessage.id}") {
.firstOrNull { it.message.reasoningText.isNotBlank() } Column(modifier = Modifier.padding(bottom = 12.dp)) {
if (turnReasoning != null) { renderChatBubble(
val reasoning = turnReasoning.message message = userMessage,
item(key = "reasoning-${reasoning.id}") { searchQuery = if (searchState.isActive) searchState.query else "",
Column(modifier = Modifier.padding(bottom = 6.dp)) { isCurrentMatch =
// Lead the turn with the assistant identity searchState.currentMatchId != null &&
// and timestamp, then the thinking block. searchState.currentMatchId == userMessage.id,
AssistantTurnHeader(timestamp = reasoning.timestamp) onOpenAttachment = viewModel::openAttachment,
ReasoningCard( onSaveAttachment = onSaveAttachment,
reasoningText = reasoning.reasoningText, savingAttachmentPath = savingAttachmentPath,
isStreaming = reasoning.isStreaming, openingAttachmentPath = openingAttachmentPath,
onImageClick = onImageClick,
) )
milestone?.let { count ->
ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn)
}
} }
} }
entryIndex++
} }
turn.entries.forEach { entry ->
when (entry) {
is AgentEntry.Prose -> {
val proseMessage = entry.message
val showTurnHeader = !firstProseSeen && turnReasoning == null
val hoistedReasoning =
turnReasoning != null &&
proseMessage.id == turnReasoning.message.id
val milestone = toolMilestones[entryIndex]
item(key = "prose-${proseMessage.id}") {
Column(modifier = Modifier.padding(bottom = 12.dp)) {
if (proseMessage.isStreaming && typingEffectEnabled) {
StreamingFullBleedWithTypingEffect(
streaming = proseMessage,
typingDelayMs = typingEffectDelayMs,
isDark = isDark,
showTurnHeader = showTurnHeader,
showReasoning = !hoistedReasoning,
)
} else {
FullBleedAgentMessage(
message = proseMessage,
showTurnHeader = showTurnHeader,
isDarkTheme = isDark,
// Highlight only bubbles that actually contain a match —
// the rest skip the highlight scan entirely.
searchQuery =
if (searchState.isActive &&
proseMessage.id in searchState.matchedIds
) {
searchState.query
} else {
""
},
isCurrentMatch =
searchState.currentMatchId != null &&
searchState.currentMatchId == proseMessage.id,
showReasoning = !hoistedReasoning,
onOpenAttachment = viewModel::openAttachment,
onSaveAttachment = onSaveAttachment,
savingAttachmentPath = savingAttachmentPath,
openingAttachmentPath = openingAttachmentPath,
canSaveAttachment = savingAttachmentPath == null,
onImageClick = onImageClick,
)
}
milestone?.let { count ->
ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn)
}
}
}
firstProseSeen = true
entryIndex++
}
is AgentEntry.ToolRow -> { is ChatTurn.Agent -> {
val toolMessage = entry.message var firstProseSeen = false
val milestone = toolMilestones[entryIndex] // Reasoning hoist: the turn's reasoning renders at the
item(key = "tool-${toolMessage.id}") { // TOP of the turn — above tool rows — so thinking
Column(modifier = Modifier.padding(bottom = 6.dp)) { // leads, then the tool work, then the answer. The
FullBleedToolRow(toolMessage) // matching prose entry renders without its own card.
milestone?.let { count -> val turnReasoning =
ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn) turn.entries
} .filterIsInstance<AgentEntry.Prose>()
} .firstOrNull { it.message.reasoningText.isNotBlank() }
if (turnReasoning != null) {
val reasoning = turnReasoning.message
item(key = "reasoning-${reasoning.id}") {
Column(modifier = Modifier.padding(bottom = 6.dp)) {
// Lead the turn with the assistant identity
// and timestamp, then the thinking block.
AssistantTurnHeader(timestamp = reasoning.timestamp)
ReasoningCard(
reasoningText = reasoning.reasoningText,
isStreaming = reasoning.isStreaming,
)
} }
entryIndex++
} }
}
is AgentEntry.SystemEvent -> { turn.entries.forEach { entry ->
val sysMessage = entry.message when (entry) {
item(key = "sys-${sysMessage.id}") { is AgentEntry.Prose -> {
Column(modifier = Modifier.padding(bottom = 6.dp)) { val proseMessage = entry.message
if (sysMessage.displayKind != null) { val showTurnHeader = !firstProseSeen && turnReasoning == null
// Timeline marker (issue #904): val hoistedReasoning =
// model/personality switches and turnReasoning != null &&
// auto-continues render as a chip. proseMessage.id == turnReasoning.message.id
TimelineMarkerChip(message = sysMessage) val milestone = toolMilestones[entryIndex]
} else { item(key = "prose-${proseMessage.id}") {
FullBleedSystemEvent( Column(modifier = Modifier.padding(bottom = 12.dp)) {
message = sysMessage, if (proseMessage.isStreaming && typingEffectEnabled) {
onRespondApproval = viewModel::respondToApproval, StreamingFullBleedWithTypingEffect(
) streaming = proseMessage,
typingDelayMs = typingEffectDelayMs,
isDark = isDark,
showTurnHeader = showTurnHeader,
showReasoning = !hoistedReasoning,
)
} else {
FullBleedAgentMessage(
message = proseMessage,
showTurnHeader = showTurnHeader,
isDarkTheme = isDark,
// Highlight only bubbles that actually contain a match —
// the rest skip the highlight scan entirely.
searchQuery =
if (searchState.isActive &&
proseMessage.id in searchState.matchedIds
) {
searchState.query
} else {
""
},
isCurrentMatch =
searchState.currentMatchId != null &&
searchState.currentMatchId == proseMessage.id,
showReasoning = !hoistedReasoning,
onOpenAttachment = viewModel::openAttachment,
onSaveAttachment = onSaveAttachment,
savingAttachmentPath = savingAttachmentPath,
openingAttachmentPath = openingAttachmentPath,
canSaveAttachment = savingAttachmentPath == null,
onImageClick = onImageClick,
)
}
milestone?.let { count ->
ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn)
}
} }
} }
firstProseSeen = true
entryIndex++
}
is AgentEntry.ToolRow -> {
val toolMessage = entry.message
val milestone = toolMilestones[entryIndex]
item(key = "tool-${toolMessage.id}") {
Column(modifier = Modifier.padding(bottom = 6.dp)) {
FullBleedToolRow(toolMessage)
milestone?.let { count ->
ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn)
}
}
}
entryIndex++
}
is AgentEntry.SystemEvent -> {
val sysMessage = entry.message
item(key = "sys-${sysMessage.id}") {
Column(modifier = Modifier.padding(bottom = 6.dp)) {
if (sysMessage.displayKind != null) {
// Timeline marker (issue #904):
// model/personality switches and
// auto-continues render as a chip.
TimelineMarkerChip(message = sysMessage)
} else {
FullBleedSystemEvent(
message = sysMessage,
onRespondApproval = viewModel::respondToApproval,
)
}
}
}
entryIndex++
} }
entryIndex++
} }
} }
} }
} }
} }
}
// Clarify bubble — rendered at the very bottom // Clarify bubble — rendered at the very bottom
if (clarifyRequest != null) { if (clarifyRequest != null) {
item(key = "clarify_bubble") { item(key = "clarify_bubble") {
ClarifyBubble( ClarifyBubble(
text = clarifyRequest.text, text = clarifyRequest.text,
options = clarifyRequest.options, options = clarifyRequest.options,
onOptionSelected = { option -> onRespondClarify?.invoke(option) }, onOptionSelected = { option -> onRespondClarify?.invoke(option) },
onDismiss = { onDismissClarify?.invoke() }, onDismiss = { onDismissClarify?.invoke() },
) )
}
} }
} }
} }
@@ -191,6 +191,8 @@ internal fun SettingsChatPage(
onTypingEffectEnabledChange = viewModel::onTypingEffectEnabledChange, onTypingEffectEnabledChange = viewModel::onTypingEffectEnabledChange,
typingEffectDelayMs = state.typingEffectDelayMs, typingEffectDelayMs = state.typingEffectDelayMs,
onTypingEffectDelayMsChange = viewModel::onTypingEffectDelayMsChange, onTypingEffectDelayMsChange = viewModel::onTypingEffectDelayMsChange,
chatFontScale = state.chatFontScale,
onChatFontScaleChange = viewModel::onChatFontScaleChange,
) )
} }
} }
@@ -36,6 +36,7 @@ data class SettingsUiState(
val isSaved: Boolean = false, val isSaved: Boolean = false,
val typingEffectEnabled: Boolean = false, val typingEffectEnabled: Boolean = false,
val typingEffectDelayMs: Int = 30, val typingEffectDelayMs: Int = 30,
val chatFontScale: Float = 1.0f,
val profiles: List<ConnectionProfile> = emptyList(), val profiles: List<ConnectionProfile> = emptyList(),
val selectedProfileId: String? = null, val selectedProfileId: String? = null,
val renameProfileName: String = "", val renameProfileName: String = "",
@@ -76,6 +77,7 @@ class SettingsViewModel(
val themePreset = AuthManager.getThemePreset() val themePreset = AuthManager.getThemePreset()
val typingEffectEnabled = AuthManager.isTypingEffectEnabled() val typingEffectEnabled = AuthManager.isTypingEffectEnabled()
val typingEffectDelayMs = AuthManager.getTypingEffectDelayMs() val typingEffectDelayMs = AuthManager.getTypingEffectDelayMs()
val chatFontScale = AuthManager.getChatFontScale()
val profiles = AuthManager.getConnectionProfiles() val profiles = AuthManager.getConnectionProfiles()
val appLanguage = AuthManager.getAppLanguage() val appLanguage = AuthManager.getAppLanguage()
val renameProfileName = val renameProfileName =
@@ -95,6 +97,7 @@ class SettingsViewModel(
themePreset = themePreset, themePreset = themePreset,
typingEffectEnabled = typingEffectEnabled, typingEffectEnabled = typingEffectEnabled,
typingEffectDelayMs = typingEffectDelayMs, typingEffectDelayMs = typingEffectDelayMs,
chatFontScale = chatFontScale,
profiles = profiles, profiles = profiles,
selectedProfileId = selectedId, selectedProfileId = selectedId,
renameProfileName = renameProfileName, renameProfileName = renameProfileName,
@@ -310,6 +313,11 @@ class SettingsViewModel(
AuthManager.setTypingEffectDelayMs(delayMs) AuthManager.setTypingEffectDelayMs(delayMs)
} }
fun onChatFontScaleChange(scale: Float) {
_uiState.update { it.copy(chatFontScale = scale, isSaved = false) }
AuthManager.setChatFontScale(scale)
}
/** Clear all auth credentials — logs out and returns to landing screen. */ /** Clear all auth credentials — logs out and returns to landing screen. */
fun logout() { fun logout() {
AuthManager.setToken(null) AuthManager.setToken(null)
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -18,22 +19,29 @@ 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.Slider
import androidx.compose.material3.Surface
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
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.ThemePreference import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset import com.m57.hermescontrol.theme.ThemePreset
import com.m57.hermescontrol.ui.settings.SectionCard import com.m57.hermescontrol.ui.settings.SectionCard
import kotlin.math.abs
import kotlin.math.roundToInt
@Composable @Composable
internal fun AppearanceSection( internal fun AppearanceSection(
@@ -194,10 +202,172 @@ internal fun ChatSection(
onTypingEffectEnabledChange: (Boolean) -> Unit, onTypingEffectEnabledChange: (Boolean) -> Unit,
typingEffectDelayMs: Int, typingEffectDelayMs: Int,
onTypingEffectDelayMsChange: (Int) -> Unit, onTypingEffectDelayMsChange: (Int) -> Unit,
chatFontScale: Float = 1.0f,
onChatFontScaleChange: (Float) -> Unit = {},
) { ) {
val fontScaleOptions = listOf(0.85f, 1.0f, 1.15f, 1.30f, 1.50f)
val currentIndex =
fontScaleOptions.indexOfFirst { abs(it - chatFontScale) < 0.05f }
.takeIf { it >= 0 } ?: 1
SectionCard { SectionCard {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.settings_item_chat_font_size),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = stringResource(R.string.settings_desc_chat_font_size),
style =
MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
}
val scaleLabel =
when (currentIndex) {
0 -> stringResource(R.string.settings_font_scale_small)
1 -> stringResource(R.string.settings_font_scale_default)
2 -> stringResource(R.string.settings_font_scale_large)
3 -> stringResource(R.string.settings_font_scale_xlarge)
else -> stringResource(R.string.settings_font_scale_huge)
}
Text(
text = scaleLabel,
style =
MaterialTheme.typography.labelMedium.copy(
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
),
)
}
Spacer(modifier = Modifier.height(8.dp))
Slider(
value = currentIndex.toFloat(),
onValueChange = { onChatFontScaleChange(fontScaleOptions[it.roundToInt()]) },
valueRange = 0f..(fontScaleOptions.size - 1).toFloat(),
steps = fontScaleOptions.size - 2,
modifier = Modifier.fillMaxWidth(),
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = "0.85×",
style =
MaterialTheme.typography.labelSmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
Text(
text = "1.0×",
style =
MaterialTheme.typography.labelSmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
Text(
text = "1.5×",
style =
MaterialTheme.typography.labelSmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
}
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.settings_chat_preview_title),
style =
MaterialTheme.typography.labelMedium.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
Spacer(modifier = Modifier.height(8.dp))
val currentDensity = LocalDensity.current
val previewDensity =
remember(currentDensity, chatFontScale) {
Density(
density = currentDensity.density,
fontScale = currentDensity.fontScale * chatFontScale,
)
}
CompositionLocalProvider(LocalDensity provides previewDensity) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// User bubble preview
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
Surface(
shape =
RoundedCornerShape(
topStart = 14.dp,
topEnd = 14.dp,
bottomStart = 14.dp,
bottomEnd = 4.dp,
),
color = MaterialTheme.colorScheme.primary,
) {
Text(
text = stringResource(R.string.settings_chat_preview_user),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
)
}
}
// Assistant bubble preview
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start,
) {
Surface(
shape =
RoundedCornerShape(
topStart = 14.dp,
topEnd = 14.dp,
bottomStart = 4.dp,
bottomEnd = 14.dp,
),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
Text(
text = stringResource(R.string.settings_chat_preview_agent),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
)
}
}
}
}
}
Spacer(modifier = Modifier.height(20.dp))
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
+11
View File
@@ -187,6 +187,17 @@
<string name="settings_item_typing_delay">지연: %1$d ms</string> <string name="settings_item_typing_delay">지연: %1$d ms</string>
<string name="settings_delay_10ms">10 ms</string> <string name="settings_delay_10ms">10 ms</string>
<string name="settings_delay_100ms">100 ms</string> <string name="settings_delay_100ms">100 ms</string>
<string name="settings_item_chat_font_size">채팅 글꼴 크기</string>
<string name="settings_desc_chat_font_size">채팅 메시지의 텍스트 크기를 조절합니다</string>
<string name="settings_font_scale_value">글꼴 크기: %1$s</string>
<string name="settings_font_scale_small">작게 (0.85×)</string>
<string name="settings_font_scale_default">기본 (1.0×)</string>
<string name="settings_font_scale_large">크게 (1.15×)</string>
<string name="settings_font_scale_xlarge">아주 크게 (1.3×)</string>
<string name="settings_font_scale_huge">최대 (1.5×)</string>
<string name="settings_chat_preview_title">미리보기</string>
<string name="settings_chat_preview_user">채팅 텍스트 크기를 조절할 수 있나요?</string>
<string name="settings_chat_preview_agent">완료되었습니다! 여기에서 글꼴 크기를 조정할 수 있습니다.</string>
<string name="settings_action_test_connection">연결 테스트</string> <string name="settings_action_test_connection">연결 테스트</string>
<string name="settings_sec_about">정보</string> <string name="settings_sec_about">정보</string>
<string name="settings_about_app_name">헤르메스 모바일</string> <string name="settings_about_app_name">헤르메스 모바일</string>
+11
View File
@@ -184,6 +184,17 @@
<string name="settings_item_typing_delay">延迟:%1$d 毫秒</string> <string name="settings_item_typing_delay">延迟:%1$d 毫秒</string>
<string name="settings_delay_10ms">10 毫秒</string> <string name="settings_delay_10ms">10 毫秒</string>
<string name="settings_delay_100ms">100 毫秒</string> <string name="settings_delay_100ms">100 毫秒</string>
<string name="settings_item_chat_font_size">聊天字体大小</string>
<string name="settings_desc_chat_font_size">缩放聊天消息文字大小</string>
<string name="settings_font_scale_value">字体大小:%1$s</string>
<string name="settings_font_scale_small">小 (0.85×)</string>
<string name="settings_font_scale_default">默认 (1.0×)</string>
<string name="settings_font_scale_large">大 (1.15×)</string>
<string name="settings_font_scale_xlarge">特大 (1.3×)</string>
<string name="settings_font_scale_huge">极大 (1.5×)</string>
<string name="settings_chat_preview_title">预览</string>
<string name="settings_chat_preview_user">可以调整聊天字体大小吗?</string>
<string name="settings_chat_preview_agent">已搞定!你可以在这里自定义字体缩放比例。</string>
<string name="settings_action_test_connection">测试连接</string> <string name="settings_action_test_connection">测试连接</string>
<string name="settings_sec_about">关于</string> <string name="settings_sec_about">关于</string>
<string name="settings_sec_language">语言</string> <string name="settings_sec_language">语言</string>
+11
View File
@@ -207,6 +207,17 @@
<string name="settings_item_typing_delay">Delay: %1$d ms</string> <string name="settings_item_typing_delay">Delay: %1$d ms</string>
<string name="settings_delay_10ms">10 ms</string> <string name="settings_delay_10ms">10 ms</string>
<string name="settings_delay_100ms">100 ms</string> <string name="settings_delay_100ms">100 ms</string>
<string name="settings_item_chat_font_size">Chat Font Size</string>
<string name="settings_desc_chat_font_size">Scale text size in chat messages</string>
<string name="settings_font_scale_value">Font Size: %1$s</string>
<string name="settings_font_scale_small">Small (0.85×)</string>
<string name="settings_font_scale_default">Default (1.0×)</string>
<string name="settings_font_scale_large">Large (1.15×)</string>
<string name="settings_font_scale_xlarge">Extra Large (1.3×)</string>
<string name="settings_font_scale_huge">Huge (1.5×)</string>
<string name="settings_chat_preview_title">Preview</string>
<string name="settings_chat_preview_user">Can you adjust the chat text size?</string>
<string name="settings_chat_preview_agent">Done! You can customize the font scale right here.</string>
<string name="settings_action_test_connection">Test Connection</string> <string name="settings_action_test_connection">Test Connection</string>
<string name="settings_sec_about">About</string> <string name="settings_sec_about">About</string>
<string name="settings_sec_language">Language</string> <string name="settings_sec_language">Language</string>
@@ -15,6 +15,7 @@ class ServerStoreTest {
assertEquals(9119, state.port) assertEquals(9119, state.port)
assertTrue(state.autoReconnect) assertTrue(state.autoReconnect)
assertEquals("token", state.wsAuthParam) assertEquals("token", state.wsAuthParam)
assertEquals(1.0f, state.chatFontScale)
assertTrue(state.connectionProfiles.isEmpty()) assertTrue(state.connectionProfiles.isEmpty())
assertNull(state.selectedProfileId) assertNull(state.selectedProfileId)
} }
@@ -68,6 +68,7 @@ class SettingsViewModelTest {
every { AuthManager.getThemePreset() } returns ThemePreset.DEFAULT every { AuthManager.getThemePreset() } returns ThemePreset.DEFAULT
every { AuthManager.isTypingEffectEnabled() } returns true every { AuthManager.isTypingEffectEnabled() } returns true
every { AuthManager.getTypingEffectDelayMs() } returns 30 every { AuthManager.getTypingEffectDelayMs() } returns 30
every { AuthManager.getChatFontScale() } returns 1.0f
every { AuthManager.getConnectionProfiles() } returns emptyList() every { AuthManager.getConnectionProfiles() } returns emptyList()
every { AuthManager.getSelectedProfileId() } answers { storedSelectedProfileId } every { AuthManager.getSelectedProfileId() } answers { storedSelectedProfileId }
every { AuthManager.baseUrl() } returns "http://127.0.0.1:9119/" every { AuthManager.baseUrl() } returns "http://127.0.0.1:9119/"
@@ -79,6 +80,7 @@ class SettingsViewModelTest {
every { AuthManager.setThemePreset(any()) } returns Unit every { AuthManager.setThemePreset(any()) } returns Unit
every { AuthManager.setTypingEffectEnabled(any()) } returns Unit every { AuthManager.setTypingEffectEnabled(any()) } returns Unit
every { AuthManager.setTypingEffectDelayMs(any()) } returns Unit every { AuthManager.setTypingEffectDelayMs(any()) } returns Unit
every { AuthManager.setChatFontScale(any()) } returns Unit
every { AuthManager.setSelectedProfileId(any()) } answers { every { AuthManager.setSelectedProfileId(any()) } answers {
storedSelectedProfileId = firstArg() storedSelectedProfileId = firstArg()
} }
@@ -130,6 +132,7 @@ class SettingsViewModelTest {
every { AuthManager.getThemePreset() } returns ThemePreset.CATPPUCCIN every { AuthManager.getThemePreset() } returns ThemePreset.CATPPUCCIN
every { AuthManager.isTypingEffectEnabled() } returns false every { AuthManager.isTypingEffectEnabled() } returns false
every { AuthManager.getTypingEffectDelayMs() } returns 15 every { AuthManager.getTypingEffectDelayMs() } returns 15
every { AuthManager.getChatFontScale() } returns 1.30f
every { AuthManager.getConnectionProfiles() } returns testProfiles every { AuthManager.getConnectionProfiles() } returns testProfiles
every { AuthManager.getAppLanguage() } returns "fr" every { AuthManager.getAppLanguage() } returns "fr"
@@ -146,12 +149,23 @@ class SettingsViewModelTest {
assertEquals(ThemePreset.CATPPUCCIN, state.themePreset) assertEquals(ThemePreset.CATPPUCCIN, state.themePreset)
assertEquals(false, state.typingEffectEnabled) assertEquals(false, state.typingEffectEnabled)
assertEquals(15, state.typingEffectDelayMs) assertEquals(15, state.typingEffectDelayMs)
assertEquals(1.30f, state.chatFontScale)
assertEquals(testProfiles, state.profiles) assertEquals(testProfiles, state.profiles)
assertEquals("prof-2", state.selectedProfileId) assertEquals("prof-2", state.selectedProfileId)
assertEquals("Home", state.renameProfileName) assertEquals("Home", state.renameProfileName)
assertEquals("fr", state.appLanguage) assertEquals("fr", state.appLanguage)
} }
@Test
fun testOnChatFontScaleChange_updatesStateAndAuthManager() {
val viewModel = createViewModel()
viewModel.onChatFontScaleChange(1.15f)
assertEquals(1.15f, viewModel.uiState.value.chatFontScale)
verify { AuthManager.setChatFontScale(1.15f) }
}
@Test @Test
fun testLoadSettings_noSelectedProfile_renameEmpty() { fun testLoadSettings_noSelectedProfile_renameEmpty() {
every { AuthManager.getConnectionProfiles() } returns testProfiles every { AuthManager.getConnectionProfiles() } returns testProfiles