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,