diff --git a/app/src/main/java/com/m57/hermescontrol/MainActivity.kt b/app/src/main/java/com/m57/hermescontrol/MainActivity.kt index 75692ca4..1c73dc76 100644 --- a/app/src/main/java/com/m57/hermescontrol/MainActivity.kt +++ b/app/src/main/java/com/m57/hermescontrol/MainActivity.kt @@ -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(), diff --git a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt index 0aaa86e2..1de600f7 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt @@ -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", diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index f290768e..4c06b7bf 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -74,6 +74,9 @@ object AuthManager { private val _themePresetFlow = MutableStateFlow(ThemePreset.DEFAULT) val themePresetFlow: StateFlow = _themePresetFlow.asStateFlow() + private val _chatFontScaleFlow = MutableStateFlow(1.0f) + val chatFontScaleFlow: StateFlow = _chatFontScaleFlow.asStateFlow() + private val _tokenFlow = MutableStateFlow(null) val tokenFlow: StateFlow = _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). */ diff --git a/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt b/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt index 66db1e83..5767ec7e 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt index 194a0e21..d39b926d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt @@ -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, + ) + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt index 58e8e8b9..5f3eccdf 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt @@ -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() - .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() + .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() }, + ) + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt index b97d5f68..7e019895 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt @@ -191,6 +191,8 @@ internal fun SettingsChatPage( onTypingEffectEnabledChange = viewModel::onTypingEffectEnabledChange, typingEffectDelayMs = state.typingEffectDelayMs, onTypingEffectDelayMsChange = viewModel::onTypingEffectDelayMsChange, + chatFontScale = state.chatFontScale, + onChatFontScaleChange = viewModel::onChatFontScaleChange, ) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt index 400d71b8..7d542fd0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt @@ -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 = 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) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt index 9df266d1..fab1dd3b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt @@ -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, diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 18ba68fb..e281ef44 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -187,6 +187,17 @@ 지연: %1$d ms 10 ms 100 ms + 채팅 글꼴 크기 + 채팅 메시지의 텍스트 크기를 조절합니다 + 글꼴 크기: %1$s + 작게 (0.85×) + 기본 (1.0×) + 크게 (1.15×) + 아주 크게 (1.3×) + 최대 (1.5×) + 미리보기 + 채팅 텍스트 크기를 조절할 수 있나요? + 완료되었습니다! 여기에서 글꼴 크기를 조정할 수 있습니다. 연결 테스트 정보 헤르메스 모바일 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index e015cf23..15c51dc7 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -184,6 +184,17 @@ 延迟:%1$d 毫秒 10 毫秒 100 毫秒 + 聊天字体大小 + 缩放聊天消息文字大小 + 字体大小:%1$s + 小 (0.85×) + 默认 (1.0×) + 大 (1.15×) + 特大 (1.3×) + 极大 (1.5×) + 预览 + 可以调整聊天字体大小吗? + 已搞定!你可以在这里自定义字体缩放比例。 测试连接 关于 语言 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 77e22e62..c24ab7cf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -207,6 +207,17 @@ Delay: %1$d ms 10 ms 100 ms + Chat Font Size + Scale text size in chat messages + Font Size: %1$s + Small (0.85×) + Default (1.0×) + Large (1.15×) + Extra Large (1.3×) + Huge (1.5×) + Preview + Can you adjust the chat text size? + Done! You can customize the font scale right here. Test Connection About Language diff --git a/app/src/test/java/com/m57/hermescontrol/data/config/ServerStoreTest.kt b/app/src/test/java/com/m57/hermescontrol/data/config/ServerStoreTest.kt index 9441ef35..2996d277 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/config/ServerStoreTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/config/ServerStoreTest.kt @@ -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) } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt index 3b3e94c6..c50acacc 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt @@ -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