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

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-29 11:36:56 +03:00
1 parent db3f8c736e
commit 5bc6e5f855
14 files changed
+461 -182

No files matched your search

@@ -54,10 +54,12 @@ class MainActivity : ComponentActivity() {
val themePreference by AuthManager.themePreferenceFlow.collectAsState()
val useDynamicColors by AuthManager.useDynamicColorsFlow.collectAsState()
val themePreset by AuthManager.themePresetFlow.collectAsState()
val chatFontScale by AuthManager.chatFontScaleFlow.collectAsState()
HermesControlTheme(
themePreference = themePreference,
useDynamicColors = useDynamicColors,
themePreset = themePreset,
chatFontScale = chatFontScale,
) {
Surface(
modifier = Modifier.fillMaxSize(),
@@ -21,6 +21,7 @@ data class ServerStoreState(
val wsAuthParam: String = "token",
val typingEffectEnabled: Boolean = true,
val typingEffectDelayMs: Int = 30,
val chatFontScale: Float = 1.0f,
// App display language. "system" = follow device locale; otherwise a BCP-47
// language code such as "en" or "ko". Applied via ContextWrapper in MainActivity.
val appLanguage: String = "system",
@@ -74,6 +74,9 @@ object AuthManager {
private val _themePresetFlow = MutableStateFlow<ThemePreset>(ThemePreset.DEFAULT)
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)
val tokenFlow: StateFlow<String?> = _tokenFlow.asStateFlow()
@@ -180,6 +183,7 @@ object AuthManager {
_themePreferenceFlow.value = state.themePreference
_useDynamicColorsFlow.value = state.useDynamicColors
_themePresetFlow.value = state.themePreset
_chatFontScaleFlow.value = state.chatFontScale
// B7 (Jul 08 2026, kanban t_470): keep cookie scope aligned with active profile.
appScope?.launch { syncCookieStoreForProfile(state.selectedProfileId) }
}
@@ -640,6 +644,15 @@ object AuthManager {
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) ─────────────────────────────────
/** 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 LocalThemePreset = compositionLocalOf { ThemePreset.DEFAULT }
val LocalChatFontScale = compositionLocalOf { 1.0f }
/**
* The 6 preset themes — one file each, all built from the same
@@ -75,6 +76,7 @@ fun HermesControlTheme(
themePreference: ThemePreference = LocalThemePreference.current,
useDynamicColors: Boolean = true,
themePreset: ThemePreset = ThemePreset.DEFAULT,
chatFontScale: Float = LocalChatFontScale.current,
content: @Composable () -> Unit,
) {
val darkTheme =
@@ -103,6 +105,7 @@ fun HermesControlTheme(
CompositionLocalProvider(
LocalThemePreference provides themePreference,
LocalThemePreset provides themePreset,
LocalChatFontScale provides chatFontScale,
LocalHermesStatusColors provides statusColors,
LocalSpacing provides SpacingDefaults,
LocalMotion provides MotionDefaults,
@@ -32,6 +32,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -43,14 +44,17 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.LocalChatFontScale
import com.m57.hermescontrol.ui.chat.MarkdownText
import com.m57.hermescontrol.ui.common.BotAvatar
import com.m57.hermescontrol.ui.common.EmptyState
@@ -141,38 +145,50 @@ fun GroupChatScreen(
}
else -> {
LazyColumn(
state = listState,
modifier =
Modifier
.weight(1f)
.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(
items = state.messages,
key = { it.id },
) { message ->
GroupMessageCard(message = message)
val currentDensity = LocalDensity.current
val chatFontScale = LocalChatFontScale.current
val chatDensity =
remember(currentDensity, chatFontScale) {
Density(
density = currentDensity.density,
fontScale = currentDensity.fontScale * chatFontScale,
)
}
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,
)
CompositionLocalProvider(LocalDensity provides chatDensity) {
LazyColumn(
state = listState,
modifier =
Modifier
.weight(1f)
.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(
items = state.messages,
key = { it.id },
) { message ->
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.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.LocalChatFontScale
import com.m57.hermescontrol.ui.chat.ChatBubble
import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.ChatSearchState
@@ -116,179 +120,191 @@ fun FullBleedChatList(
}
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp),
) {
if (isLoadingOlder) {
item(key = "loading-older") {
Box(
modifier = Modifier.fillMaxWidth().padding(12.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
}
val currentDensity = LocalDensity.current
val chatFontScale = LocalChatFontScale.current
val chatDensity =
remember(currentDensity, chatFontScale) {
Density(
density = currentDensity.density,
fontScale = currentDensity.fontScale * chatFontScale,
)
}
var entryIndex = 0
turns.forEach { turn ->
when (turn) {
is ChatTurn.User -> {
// Eager captures: item lambda reads these at
// composition time (lazy), so capture now.
val userMessage = turn.message
val milestone = toolMilestones[entryIndex]
item(key = "user-${userMessage.id}") {
Column(modifier = Modifier.padding(bottom = 12.dp)) {
renderChatBubble(
message = userMessage,
searchQuery = if (searchState.isActive) searchState.query else "",
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)
}
}
CompositionLocalProvider(LocalDensity provides chatDensity) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp),
) {
if (isLoadingOlder) {
item(key = "loading-older") {
Box(
modifier = Modifier.fillMaxWidth().padding(12.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
entryIndex++
}
}
is ChatTurn.Agent -> {
var firstProseSeen = false
// Reasoning hoist: the turn's reasoning renders at the
// TOP of the turn — above tool rows — so thinking
// leads, then the tool work, then the answer. The
// matching prose entry renders without its own card.
val turnReasoning =
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,
var entryIndex = 0
turns.forEach { turn ->
when (turn) {
is ChatTurn.User -> {
// Eager captures: item lambda reads these at
// composition time (lazy), so capture now.
val userMessage = turn.message
val milestone = toolMilestones[entryIndex]
item(key = "user-${userMessage.id}") {
Column(modifier = Modifier.padding(bottom = 12.dp)) {
renderChatBubble(
message = userMessage,
searchQuery = if (searchState.isActive) searchState.query else "",
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++
}
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 -> {
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)
}
}
is ChatTurn.Agent -> {
var firstProseSeen = false
// Reasoning hoist: the turn's reasoning renders at the
// TOP of the turn — above tool rows — so thinking
// leads, then the tool work, then the answer. The
// matching prose entry renders without its own card.
val turnReasoning =
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 -> {
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,
)
}
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 -> {
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
if (clarifyRequest != null) {
item(key = "clarify_bubble") {
ClarifyBubble(
text = clarifyRequest.text,
options = clarifyRequest.options,
onOptionSelected = { option -> onRespondClarify?.invoke(option) },
onDismiss = { onDismissClarify?.invoke() },
)
// Clarify bubble — rendered at the very bottom
if (clarifyRequest != null) {
item(key = "clarify_bubble") {
ClarifyBubble(
text = clarifyRequest.text,
options = clarifyRequest.options,
onOptionSelected = { option -> onRespondClarify?.invoke(option) },
onDismiss = { onDismissClarify?.invoke() },
)
}
}
}
}
@@ -191,6 +191,8 @@ internal fun SettingsChatPage(
onTypingEffectEnabledChange = viewModel::onTypingEffectEnabledChange,
typingEffectDelayMs = state.typingEffectDelayMs,
onTypingEffectDelayMsChange = viewModel::onTypingEffectDelayMsChange,
chatFontScale = state.chatFontScale,
onChatFontScaleChange = viewModel::onChatFontScaleChange,
)
}
}
@@ -36,6 +36,7 @@ data class SettingsUiState(
val isSaved: Boolean = false,
val typingEffectEnabled: Boolean = false,
val typingEffectDelayMs: Int = 30,
val chatFontScale: Float = 1.0f,
val profiles: List<ConnectionProfile> = emptyList(),
val selectedProfileId: String? = null,
val renameProfileName: String = "",
@@ -76,6 +77,7 @@ class SettingsViewModel(
val themePreset = AuthManager.getThemePreset()
val typingEffectEnabled = AuthManager.isTypingEffectEnabled()
val typingEffectDelayMs = AuthManager.getTypingEffectDelayMs()
val chatFontScale = AuthManager.getChatFontScale()
val profiles = AuthManager.getConnectionProfiles()
val appLanguage = AuthManager.getAppLanguage()
val renameProfileName =
@@ -95,6 +97,7 @@ class SettingsViewModel(
themePreset = themePreset,
typingEffectEnabled = typingEffectEnabled,
typingEffectDelayMs = typingEffectDelayMs,
chatFontScale = chatFontScale,
profiles = profiles,
selectedProfileId = selectedId,
renameProfileName = renameProfileName,
@@ -310,6 +313,11 @@ class SettingsViewModel(
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. */
fun logout() {
AuthManager.setToken(null)
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
@@ -18,22 +19,29 @@ import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
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 com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
import com.m57.hermescontrol.ui.settings.SectionCard
import kotlin.math.abs
import kotlin.math.roundToInt
@Composable
internal fun AppearanceSection(
@@ -194,10 +202,172 @@ internal fun ChatSection(
onTypingEffectEnabledChange: (Boolean) -> Unit,
typingEffectDelayMs: Int,
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 {
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))
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(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
+11
View File
@@ -187,6 +187,17 @@
<string name="settings_item_typing_delay">지연: %1$d ms</string>
<string name="settings_delay_10ms">10 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_sec_about">정보</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_delay_10ms">10 毫秒</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_sec_about">关于</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_delay_10ms">10 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_sec_about">About</string>
<string name="settings_sec_language">Language</string>
@@ -15,6 +15,7 @@ class ServerStoreTest {
assertEquals(9119, state.port)
assertTrue(state.autoReconnect)
assertEquals("token", state.wsAuthParam)
assertEquals(1.0f, state.chatFontScale)
assertTrue(state.connectionProfiles.isEmpty())
assertNull(state.selectedProfileId)
}
@@ -68,6 +68,7 @@ class SettingsViewModelTest {
every { AuthManager.getThemePreset() } returns ThemePreset.DEFAULT
every { AuthManager.isTypingEffectEnabled() } returns true
every { AuthManager.getTypingEffectDelayMs() } returns 30
every { AuthManager.getChatFontScale() } returns 1.0f
every { AuthManager.getConnectionProfiles() } returns emptyList()
every { AuthManager.getSelectedProfileId() } answers { storedSelectedProfileId }
every { AuthManager.baseUrl() } returns "http://127.0.0.1:9119/"
@@ -79,6 +80,7 @@ class SettingsViewModelTest {
every { AuthManager.setThemePreset(any()) } returns Unit
every { AuthManager.setTypingEffectEnabled(any()) } returns Unit
every { AuthManager.setTypingEffectDelayMs(any()) } returns Unit
every { AuthManager.setChatFontScale(any()) } returns Unit
every { AuthManager.setSelectedProfileId(any()) } answers {
storedSelectedProfileId = firstArg()
}
@@ -130,6 +132,7 @@ class SettingsViewModelTest {
every { AuthManager.getThemePreset() } returns ThemePreset.CATPPUCCIN
every { AuthManager.isTypingEffectEnabled() } returns false
every { AuthManager.getTypingEffectDelayMs() } returns 15
every { AuthManager.getChatFontScale() } returns 1.30f
every { AuthManager.getConnectionProfiles() } returns testProfiles
every { AuthManager.getAppLanguage() } returns "fr"
@@ -146,12 +149,23 @@ class SettingsViewModelTest {
assertEquals(ThemePreset.CATPPUCCIN, state.themePreset)
assertEquals(false, state.typingEffectEnabled)
assertEquals(15, state.typingEffectDelayMs)
assertEquals(1.30f, state.chatFontScale)
assertEquals(testProfiles, state.profiles)
assertEquals("prof-2", state.selectedProfileId)
assertEquals("Home", state.renameProfileName)
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
fun testLoadSettings_noSelectedProfile_renameEmpty() {
every { AuthManager.getConnectionProfiles() } returns testProfiles