refactor(#285): extract section composables from SettingsScreen and ChatScreen (#358)

* refactor(#285): extract ConnectionSection from SettingsScreen

* refactor(#285): extract TestResultCard, SaveIndicator, SettingsActionButtons

* refactor(#285): extract BehaviorSection from SettingsScreen

* refactor(#285): extract AppearanceSection from SettingsScreen

* refactor(#285): extract ChatSection from SettingsScreen

* refactor(#285): extract NavBarSection from SettingsScreen

* refactor(#285): extract AboutSection from SettingsScreen

* refactor(#285): extract ChatLifecycleEffects from ChatScreen

* refactor(#285): extract ChatTopBanner from ChatScreen

* refactor(#285): extract ChatMessageList, ChatLoadingOverlay, ChatScrollToBottomFab

* fix(#285): use fully-qualified ButtonDefaults, BoxScope receiver for ChatScrollToBottomFab

* fix(#285): add BoxScope import

* fix(#285): pass isLoading as param instead of accessing StateFlow.value

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-06-25 20:36:21 +03:00
1 parent a3b794be5c
commit 9297413b2a
2 files changed
+694 -445

No files matched your search

@@ -25,6 +25,7 @@ import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -123,7 +124,6 @@ fun ChatScreen(
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val listState = rememberLazyListState() val listState = rememberLazyListState()
var lastSessionId by remember { mutableStateOf<String?>(null) }
val showScrollToBottom by remember { val showScrollToBottom by remember {
derivedStateOf { derivedStateOf {
state.messages.isNotEmpty() && listState.canScrollForward state.messages.isNotEmpty() && listState.canScrollForward
@@ -135,116 +135,24 @@ fun ChatScreen(
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val isDark = isSystemInDarkTheme() val isDark = isSystemInDarkTheme()
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
// Publish the notification session ID to the ViewModel synchronously // Lifecycle effects, permissions, session switching, auto-scroll, errors
// during composition — BEFORE any WebSocket event (GatewayReady) can be ChatLifecycleEffects(
// processed. This ensures GatewayReady sees the pending session and sessionId = sessionId,
// resumes it instead of creating a new empty chat (issue #240). connectionStatus = state.connectionStatus,
SideEffect { currentSessionId = state.currentSessionId,
viewModel.initialSessionId = sessionId messages = state.messages,
} streamingMessage = state.streamingMessage,
isThinking = state.isThinking,
// Switch to the session from a notification tap or history screen when provided. errorMessage = state.errorMessage,
// Keyed on connectionStatus so switchSession only fires after the WS is isSearchActive = state.isSearchActive,
// connected — otherwise SESSION_RESUME is silently dropped (webSocket is currentSearchMatchIndex = state.currentSearchMatchIndex,
// null before onOpen). GatewayReady also handles initialSessionId as a searchMatchIndices = state.searchMatchIndices,
// fallback for the race where it fires before this effect. clarifyRequest = state.clarifyRequest,
val pendingSessionId = NavigationController.pendingSessionId listState = listState,
LaunchedEffect(sessionId, pendingSessionId, state.connectionStatus) { snackbarHostState = snackbarHostState,
if (state.connectionStatus != ConnectionStatus.CONNECTED) return@LaunchedEffect viewModel = viewModel,
val target = if (!sessionId.isNullOrBlank()) sessionId else pendingSessionId
if (!target.isNullOrBlank()) {
viewModel.switchSession(target)
if (target == pendingSessionId) {
NavigationController.pendingSessionId = null
}
}
}
// Manage notification foreground service lifecycle:
// - When app goes to background (ON_STOP), mark as not-foreground and
// start the notification service so we can post reply notifications.
// - When app returns to foreground (ON_START), mark as foreground and
// stop the notification service.
DisposableEffect(lifecycleOwner) {
val observer =
androidx.lifecycle.LifecycleEventObserver { _, event ->
when (event) {
androidx.lifecycle.Lifecycle.Event.ON_START -> {
NotificationHelper.setAppForeground(context, true)
NotificationHelper.stop(context)
viewModel.refreshSettings()
viewModel.refreshCurrentSession()
}
androidx.lifecycle.Lifecycle.Event.ON_STOP -> {
NotificationHelper.setAppForeground(context, false)
NotificationHelper.start(context)
}
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
// Request POST_NOTIFICATIONS runtime permission on Android 13+ (API 33).
// The manifest declaration alone is insufficient — on API 33+ the system
// requires a runtime permission prompt before the app can post any
// notification, including the foreground service notification.
val requestNotificationPermission =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { /* granted — next lifecycle event will pick it up */ }
LaunchedEffect(Unit) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val permission = android.Manifest.permission.POST_NOTIFICATIONS
if (
ContextCompat.checkSelfPermission(context, permission) !=
android.content.pm.PackageManager.PERMISSION_GRANTED
) {
requestNotificationPermission.launch(permission)
}
}
}
// Auto-scroll to bottom on new messages
LaunchedEffect(state.messages.size, state.streamingMessage?.content?.length, state.isThinking) {
val totalItems =
state.messages.size +
(if (state.streamingMessage != null) 1 else 0) +
(if (state.isThinking) 1 else 0)
if (totalItems > 0) {
val isSessionSwitch = state.currentSessionId != lastSessionId
if (isSessionSwitch) {
lastSessionId = state.currentSessionId
listState.scrollToItem(totalItems - 1)
} else if (listState.isAtBottom()) {
listState.animateScrollToItem(totalItems - 1)
}
}
}
// Show error as snackbar
LaunchedEffect(state.errorMessage) {
state.errorMessage?.let { error ->
snackbarHostState.showSnackbar(error)
viewModel.clearError()
}
}
// Clarify dialog
state.clarifyRequest?.let { clarify ->
ClarifyDialog(
clarify = clarify,
onOptionSelected = viewModel::respondToClarify,
onDismiss = viewModel::dismissClarify,
) )
}
val backgroundGradient = val backgroundGradient =
Brush.verticalGradient( Brush.verticalGradient(
@@ -256,20 +164,6 @@ fun ChatScreen(
), ),
) )
// Scroll to current search match
LaunchedEffect(state.isSearchActive, state.currentSearchMatchIndex, state.searchMatchIndices) {
if (state.isSearchActive &&
state.currentSearchMatchIndex >= 0 &&
state.currentSearchMatchIndex < state.searchMatchIndices.size &&
state.messages.isNotEmpty()
) {
val targetIndex = state.searchMatchIndices[state.currentSearchMatchIndex]
listState.animateScrollToItem(
targetIndex.coerceIn(0, state.messages.lastIndex),
)
}
}
HermesScaffold( HermesScaffold(
modifier = modifier, modifier = modifier,
pinTopBar = true, pinTopBar = true,
@@ -339,78 +233,10 @@ fun ChatScreen(
.background(backgroundGradient) .background(backgroundGradient)
.imePadding(), .imePadding(),
) { ) {
androidx.compose.animation.AnimatedVisibility( ChatTopBanner(
visible = connectionStatus = state.connectionStatus,
state.connectionStatus == ConnectionStatus.RECONNECTING || onReconnect = viewModel::reconnect,
state.connectionStatus == ConnectionStatus.DISCONNECTED,
enter = androidx.compose.animation.expandVertically() + androidx.compose.animation.fadeIn(),
exit = androidx.compose.animation.shrinkVertically() + androidx.compose.animation.fadeOut(),
) {
androidx.compose.material3.Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text =
if (state.connectionStatus == ConnectionStatus.RECONNECTING) {
stringResource(R.string.chat_status_reconnecting)
} else {
stringResource(R.string.chat_status_disconnected)
},
style = MaterialTheme.typography.bodyMedium,
) )
if (state.connectionStatus == ConnectionStatus.DISCONNECTED) {
TextButton(
onClick = { viewModel.reconnect() },
colors =
androidx.compose.material3.ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
) {
Text(stringResource(R.string.chat_action_reconnect))
}
}
}
}
}
androidx.compose.animation.AnimatedVisibility(
visible = state.isSearchActive,
enter = androidx.compose.animation.expandVertically() + androidx.compose.animation.fadeIn(),
exit = androidx.compose.animation.shrinkVertically() + androidx.compose.animation.fadeOut(),
) {
androidx.compose.material3.Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceContainerLow,
tonalElevation = 2.dp,
border =
BorderStroke(
width = 1.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.12f),
),
) {
Box(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
SearchBarRow(
searchQuery = state.searchQuery,
onQueryChange = { viewModel.setSearchQuery(it) },
searchMatchCount = state.searchMatchIndices.size,
currentMatchIndex = state.currentSearchMatchIndex,
onNavigateUp = { viewModel.navigateSearchMatch(-1) },
onNavigateDown = { viewModel.navigateSearchMatch(1) },
onClose = { viewModel.clearSearch() },
)
}
}
}
Box( Box(
modifier = modifier =
@@ -418,153 +244,38 @@ fun ChatScreen(
.weight(1f) .weight(1f)
.fillMaxWidth(), .fillMaxWidth(),
) { ) {
if (state.messages.isEmpty() && !state.isLoading) { ChatMessageList(
EmptyState( messages = state.messages,
title = stringResource(R.string.chat_empty_title), streamingMessage = state.streamingMessage,
subtitle = stringResource(R.string.chat_empty_subtitle), isThinking = state.isThinking,
) thinkingText = state.thinkingText,
} isSearchActive = state.isSearchActive,
searchQuery = state.searchQuery,
LazyColumn( currentSearchMatchIndex = state.currentSearchMatchIndex,
state = listState, searchMatchIndices = state.searchMatchIndices,
modifier = Modifier.fillMaxSize(), typingEffectEnabled = state.typingEffectEnabled,
contentPadding = PaddingValues(vertical = 8.dp), typingEffectDelayMs = state.typingEffectDelayMs,
) { isLoading = state.isLoading,
itemsIndexed(
items = state.messages,
key = { _, message -> message.id },
) { index, message ->
val isCurrentMatch =
state.isSearchActive &&
state.currentSearchMatchIndex >= 0 &&
state.currentSearchMatchIndex < state.searchMatchIndices.size &&
state.searchMatchIndices[state.currentSearchMatchIndex] == index
val isLastMessage = index == state.messages.lastIndex
val isAssistant = message.role == MessageRole.ASSISTANT
if (state.typingEffectEnabled &&
isLastMessage &&
isAssistant &&
lastAnimatedMessageId != message.id
) {
StreamingBubbleWithTypingEffect(
streaming = message,
typingDelayMs = state.typingEffectDelayMs,
isDark = isDark, isDark = isDark,
onAnimationComplete = { listState = listState,
lastAnimatedMessageId = message.id lastAnimatedMessageId = lastAnimatedMessageId,
}, onLastAnimatedMessageIdChange = { lastAnimatedMessageId = it },
viewModel = viewModel,
) )
} else {
ChatBubble(
message = message,
isDarkTheme = isDark,
searchQuery = if (state.isSearchActive) state.searchQuery else "",
isCurrentMatch = isCurrentMatch,
)
}
}
// Streaming message — rendered separately for O(1) updates
state.streamingMessage?.let { streaming ->
item(key = "streaming-${streaming.id}") {
if (state.typingEffectEnabled && streaming.isStreaming) {
StreamingBubbleWithTypingEffect(
streaming = streaming,
typingDelayMs = state.typingEffectDelayMs,
isDark = isDark,
)
} else {
ChatBubble(
message = streaming,
isDarkTheme = isDark,
searchQuery = "",
isCurrentMatch = false,
)
}
}
}
// Thinking indicator
if (state.isThinking) {
item(key = "thinking") {
ThinkingIndicator(state.thinkingText)
}
}
}
// Loading overlay // Loading overlay
androidx.compose.animation.AnimatedVisibility( ChatLoadingOverlay(isLoading = state.isLoading)
visible = state.isLoading,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.fillMaxSize(),
) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.7f)),
contentAlignment = Alignment.Center,
) {
Card(
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(4.dp),
) {
Column(
modifier = Modifier.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.chat_status_connecting),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
// Scroll-to-bottom FAB // Scroll-to-bottom FAB
androidx.compose.animation.AnimatedVisibility( ChatScrollToBottomFab(
visible = showScrollToBottom, showScrollToBottom = showScrollToBottom,
enter = fadeIn() + scaleIn(), scrollScope = scrollScope,
exit = fadeOut() + scaleOut(), listState = listState,
modifier = messages = state.messages,
Modifier streamingMessage = state.streamingMessage,
.align(Alignment.BottomEnd) isThinking = state.isThinking,
.padding(end = 16.dp, bottom = 16.dp),
) {
FloatingActionButton(
onClick = {
scrollScope.launch {
val totalItems =
state.messages.size +
(if (state.streamingMessage != null) 1 else 0) +
(if (state.isThinking) 1 else 0)
if (totalItems > 0) {
listState.animateScrollToItem(totalItems - 1)
}
}
},
modifier = Modifier.size(40.dp),
containerColor = MaterialTheme.colorScheme.surfaceContainer,
contentColor = MaterialTheme.colorScheme.onSurface,
) {
Icon(
imageVector = Icons.Filled.KeyboardArrowDown,
contentDescription = stringResource(R.string.content_desc_scroll_to_bottom),
) )
} }
}
}
ChatInputBar( ChatInputBar(
inputText = inputText, inputText = inputText,
@@ -1128,3 +839,385 @@ private fun LazyListState.isAtBottom(threshold: Int = 3): Boolean {
val lastVisibleItem = visibleItems.last() val lastVisibleItem = visibleItems.last()
return lastVisibleItem.index >= layoutInfo.totalItemsCount - threshold return lastVisibleItem.index >= layoutInfo.totalItemsCount - threshold
} }
@Composable
private fun ChatLifecycleEffects(
sessionId: String?,
connectionStatus: ConnectionStatus,
currentSessionId: String?,
messages: List<ChatMessage>,
streamingMessage: ChatMessage?,
isThinking: Boolean,
errorMessage: String?,
isSearchActive: Boolean,
currentSearchMatchIndex: Int,
searchMatchIndices: List<Int>,
clarifyRequest: ClarifyUi?,
listState: LazyListState,
snackbarHostState: SnackbarHostState,
viewModel: ChatViewModel,
) {
val context = LocalContext.current
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
// Publish the notification session ID to the ViewModel synchronously
SideEffect {
viewModel.initialSessionId = sessionId
}
// Switch to session from notification/history
var lastSessionId by remember { mutableStateOf<String?>(null) }
val pendingSessionId = NavigationController.pendingSessionId
LaunchedEffect(sessionId, pendingSessionId, connectionStatus) {
if (connectionStatus != ConnectionStatus.CONNECTED) return@LaunchedEffect
val target = if (!sessionId.isNullOrBlank()) sessionId else pendingSessionId
if (!target.isNullOrBlank()) {
viewModel.switchSession(target)
if (target == pendingSessionId) {
NavigationController.pendingSessionId = null
}
}
}
// Lifecycle observer for notification foreground service
DisposableEffect(lifecycleOwner) {
val observer =
androidx.lifecycle.LifecycleEventObserver { _, event ->
when (event) {
androidx.lifecycle.Lifecycle.Event.ON_START -> {
NotificationHelper.setAppForeground(context, true)
NotificationHelper.stop(context)
viewModel.refreshSettings()
viewModel.refreshCurrentSession()
}
androidx.lifecycle.Lifecycle.Event.ON_STOP -> {
NotificationHelper.setAppForeground(context, false)
NotificationHelper.start(context)
}
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
// Request POST_NOTIFICATIONS permission on Android 13+
val requestNotificationPermission =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { /* granted */ }
LaunchedEffect(Unit) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val permission = android.Manifest.permission.POST_NOTIFICATIONS
if (ContextCompat.checkSelfPermission(context, permission) !=
android.content.pm.PackageManager.PERMISSION_GRANTED
) {
requestNotificationPermission.launch(permission)
}
}
}
// Auto-scroll to bottom on new messages
LaunchedEffect(messages.size, streamingMessage?.content?.length, isThinking) {
val totalItems =
messages.size +
(if (streamingMessage != null) 1 else 0) +
(if (isThinking) 1 else 0)
if (totalItems > 0) {
val isSessionSwitch = currentSessionId != lastSessionId
if (isSessionSwitch) {
lastSessionId = currentSessionId
listState.scrollToItem(totalItems - 1)
} else if (listState.isAtBottom()) {
listState.animateScrollToItem(totalItems - 1)
}
}
}
// Show error as snackbar
LaunchedEffect(errorMessage) {
errorMessage?.let { error ->
snackbarHostState.showSnackbar(error)
viewModel.clearError()
}
}
// Clarify dialog
clarifyRequest?.let { clarify ->
ClarifyDialog(
clarify = clarify,
onOptionSelected = viewModel::respondToClarify,
onDismiss = viewModel::dismissClarify,
)
}
// Scroll to current search match
LaunchedEffect(isSearchActive, currentSearchMatchIndex, searchMatchIndices) {
if (isSearchActive &&
currentSearchMatchIndex >= 0 &&
currentSearchMatchIndex < searchMatchIndices.size &&
messages.isNotEmpty()
) {
val targetIndex = searchMatchIndices[currentSearchMatchIndex]
listState.animateScrollToItem(
targetIndex.coerceIn(0, messages.lastIndex),
)
}
}
}
@Composable
private fun ChatTopBanner(
connectionStatus: ConnectionStatus,
onReconnect: () -> Unit,
) {
androidx.compose.animation.AnimatedVisibility(
visible =
connectionStatus == ConnectionStatus.RECONNECTING ||
connectionStatus == ConnectionStatus.DISCONNECTED,
enter = androidx.compose.animation.expandVertically() + androidx.compose.animation.fadeIn(),
exit = androidx.compose.animation.shrinkVertically() + androidx.compose.animation.fadeOut(),
) {
androidx.compose.material3.Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text =
if (connectionStatus == ConnectionStatus.RECONNECTING) {
stringResource(R.string.chat_status_reconnecting)
} else {
stringResource(R.string.chat_status_disconnected)
},
style = MaterialTheme.typography.bodyMedium,
)
if (connectionStatus == ConnectionStatus.DISCONNECTED) {
TextButton(
onClick = onReconnect,
colors =
androidx.compose.material3.ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
) {
Text(stringResource(R.string.chat_action_reconnect))
}
}
}
}
}
}
@Composable
private fun ChatMessageList(
messages: List<ChatMessage>,
streamingMessage: ChatMessage?,
isThinking: Boolean,
thinkingText: String,
isSearchActive: Boolean,
searchQuery: String,
currentSearchMatchIndex: Int,
searchMatchIndices: List<Int>,
typingEffectEnabled: Boolean,
typingEffectDelayMs: Int,
isLoading: Boolean,
isDark: Boolean,
listState: LazyListState,
lastAnimatedMessageId: String?,
onLastAnimatedMessageIdChange: (String?) -> Unit,
viewModel: ChatViewModel,
) {
// Search bar
androidx.compose.animation.AnimatedVisibility(
visible = isSearchActive,
enter = androidx.compose.animation.expandVertically() + androidx.compose.animation.fadeIn(),
exit = androidx.compose.animation.shrinkVertically() + androidx.compose.animation.fadeOut(),
) {
androidx.compose.material3.Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceContainerLow,
tonalElevation = 2.dp,
border =
BorderStroke(
width = 1.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.12f),
),
) {
Box(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
SearchBarRow(
searchQuery = searchQuery,
onQueryChange = { viewModel.setSearchQuery(it) },
searchMatchCount = searchMatchIndices.size,
currentMatchIndex = currentSearchMatchIndex,
onNavigateUp = { viewModel.navigateSearchMatch(-1) },
onNavigateDown = { viewModel.navigateSearchMatch(1) },
onClose = { viewModel.clearSearch() },
)
}
}
}
if (messages.isEmpty() && !isLoading) {
EmptyState(
title = stringResource(R.string.chat_empty_title),
subtitle = stringResource(R.string.chat_empty_subtitle),
)
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp),
) {
itemsIndexed(
items = messages,
key = { _, message -> message.id },
) { index, message ->
val isCurrentMatch =
isSearchActive &&
currentSearchMatchIndex >= 0 &&
currentSearchMatchIndex < searchMatchIndices.size &&
searchMatchIndices[currentSearchMatchIndex] == index
val isLastMessage = index == messages.lastIndex
val isAssistant = message.role == MessageRole.ASSISTANT
if (typingEffectEnabled && isLastMessage && isAssistant &&
lastAnimatedMessageId != message.id
) {
StreamingBubbleWithTypingEffect(
streaming = message,
typingDelayMs = typingEffectDelayMs,
isDark = isDark,
onAnimationComplete = {
onLastAnimatedMessageIdChange(message.id)
},
)
} else {
ChatBubble(
message = message,
isDarkTheme = isDark,
searchQuery = if (isSearchActive) searchQuery else "",
isCurrentMatch = isCurrentMatch,
)
}
}
// Streaming message
streamingMessage?.let { streaming ->
item(key = "streaming-${streaming.id}") {
if (typingEffectEnabled && streaming.isStreaming) {
StreamingBubbleWithTypingEffect(
streaming = streaming,
typingDelayMs = typingEffectDelayMs,
isDark = isDark,
)
} else {
ChatBubble(
message = streaming,
isDarkTheme = isDark,
searchQuery = "",
isCurrentMatch = false,
)
}
}
}
// Thinking indicator
if (isThinking) {
item(key = "thinking") {
ThinkingIndicator(thinkingText)
}
}
}
}
@Composable
private fun ChatLoadingOverlay(isLoading: Boolean) {
androidx.compose.animation.AnimatedVisibility(
visible = isLoading,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.fillMaxSize(),
) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.7f)),
contentAlignment = Alignment.Center,
) {
Card(
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(4.dp),
) {
Column(
modifier = Modifier.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.chat_status_connecting),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
}
@Composable
private fun BoxScope.ChatScrollToBottomFab(
showScrollToBottom: Boolean,
scrollScope: kotlinx.coroutines.CoroutineScope,
listState: LazyListState,
messages: List<ChatMessage>,
streamingMessage: ChatMessage?,
isThinking: Boolean,
) {
androidx.compose.animation.AnimatedVisibility(
visible = showScrollToBottom,
enter = fadeIn() + scaleIn(),
exit = fadeOut() + scaleOut(),
modifier =
Modifier
.align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = 16.dp),
) {
FloatingActionButton(
onClick = {
scrollScope.launch {
val totalItems =
messages.size +
(if (streamingMessage != null) 1 else 0) +
(if (isThinking) 1 else 0)
if (totalItems > 0) {
listState.animateScrollToItem(totalItems - 1)
}
}
},
modifier = Modifier.size(40.dp),
containerColor = MaterialTheme.colorScheme.surfaceContainer,
contentColor = MaterialTheme.colorScheme.onSurface,
) {
Icon(
imageVector = Icons.Filled.KeyboardArrowDown,
contentDescription = stringResource(R.string.content_desc_scroll_to_bottom),
)
}
}
}
@@ -134,9 +134,185 @@ fun SettingsScreen(
) { ) {
when (selectedTab) { when (selectedTab) {
SettingsTab.CONNECTION -> { SettingsTab.CONNECTION -> {
// Connection section ConnectionSection(
state = state,
viewModel = viewModel,
passwordVisible = passwordVisible,
onPasswordVisibilityToggle = { passwordVisible = !passwordVisible },
)
TestResultCard(testResult = state.testResult)
SaveIndicator(isSaved = state.isSaved)
SettingsActionButtons(
isTesting = state.isTesting,
onTest = viewModel::testConnection,
onSave = viewModel::save,
)
Spacer(modifier = Modifier.height(16.dp))
}
SettingsTab.BEHAVIOR -> {
BehaviorSection(
autoReconnect = state.autoReconnect,
onAutoReconnectChange = viewModel::onAutoReconnectChange,
)
}
SettingsTab.APPEARANCE -> {
AppearanceSection(
themePreference = state.themePreference,
onThemeChange = viewModel::onThemeChange,
useDynamicColors = state.useDynamicColors,
onUseDynamicColorsChange = viewModel::onUseDynamicColorsChange,
themePreset = state.themePreset,
onThemePresetChange = viewModel::onThemePresetChange,
)
ChatSection(
typingEffectEnabled = state.typingEffectEnabled,
onTypingEffectEnabledChange = viewModel::onTypingEffectEnabledChange,
typingEffectDelayMs = state.typingEffectDelayMs,
onTypingEffectDelayMsChange = viewModel::onTypingEffectDelayMsChange,
)
NavBarSection(
bottomNavDisplayMode = state.bottomNavDisplayMode,
onBottomNavDisplayModeChange = viewModel::onBottomNavDisplayModeChange,
selectedNavItems = state.selectedNavItems,
availableNavItems = viewModel.availableNavItems,
onReorderNavItems = viewModel::reorderNavItems,
onRemoveNavItem = viewModel::removeNavItem,
onAddNavItem = viewModel::addNavItem,
)
}
SettingsTab.ABOUT -> {
// ── About section ─────────────────────────────────────────
SectionCard(title = stringResource(R.string.settings_sec_about)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(R.string.settings_about_app_name),
style =
MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.Bold,
),
)
}
Spacer(modifier = Modifier.height(8.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(8.dp))
InfoRow(
label = stringResource(R.string.settings_about_version),
value = "v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
)
InfoRow(
label = stringResource(R.string.settings_about_build),
value =
if (BuildConfig.DEBUG) {
stringResource(R.string.settings_about_debug)
} else {
stringResource(R.string.settings_about_release)
},
)
if (BuildConfig.GIT_SHA.isNotBlank()) {
InfoRow(
label = stringResource(R.string.settings_about_commit),
value = BuildConfig.GIT_SHA,
)
}
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "https://github.com/Hy4ri/hermes-mobile",
style =
MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.primary,
),
)
Spacer(modifier = Modifier.height(24.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
viewModel.logout()
onLogout()
},
modifier = Modifier.fillMaxWidth(),
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
),
) {
Text(stringResource(R.string.settings_logout))
}
}
}
}
}
}
}
}
@Composable
private fun InfoRow(
label: String,
value: String,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
)
}
}
@Composable
private fun SectionCard(
title: String,
content: @Composable () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(1.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = title,
style =
MaterialTheme.typography.titleSmall.copy(
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
),
modifier = Modifier.padding(bottom = 12.dp),
)
content()
}
}
}
@Composable
private fun ConnectionSection(
state: SettingsUiState,
viewModel: SettingsViewModel,
passwordVisible: Boolean,
onPasswordVisibilityToggle: () -> Unit,
) {
SectionCard(title = stringResource(R.string.settings_sec_connection)) { SectionCard(title = stringResource(R.string.settings_sec_connection)) {
// Connection Profiles management in Settings
if (state.profiles.isNotEmpty()) { if (state.profiles.isNotEmpty()) {
var profilesExpanded by remember { mutableStateOf(false) } var profilesExpanded by remember { mutableStateOf(false) }
Text( Text(
@@ -268,7 +444,7 @@ fun SettingsScreen(
PasswordVisualTransformation() PasswordVisualTransformation()
}, },
trailingIcon = { trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) { IconButton(onClick = onPasswordVisibilityToggle) {
Icon( Icon(
imageVector = imageVector =
if (passwordVisible) { if (passwordVisible) {
@@ -310,14 +486,16 @@ fun SettingsScreen(
Text(stringResource(R.string.settings_action_clear_token)) Text(stringResource(R.string.settings_action_clear_token))
} }
} }
}
// Test result @Composable
private fun TestResultCard(testResult: String?) {
AnimatedVisibility( AnimatedVisibility(
visible = state.testResult != null, visible = testResult != null,
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut(), exit = fadeOut(),
) { ) {
state.testResult?.let { result -> testResult?.let { result ->
Card( Card(
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
@@ -338,10 +516,12 @@ fun SettingsScreen(
} }
} }
} }
}
// Save indicator @Composable
private fun SaveIndicator(isSaved: Boolean) {
AnimatedVisibility( AnimatedVisibility(
visible = state.isSaved, visible = isSaved,
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut(), exit = fadeOut(),
) { ) {
@@ -353,18 +533,24 @@ fun SettingsScreen(
), ),
) )
} }
}
// Buttons @Composable
private fun SettingsActionButtons(
isTesting: Boolean,
onTest: () -> Unit,
onSave: () -> Unit,
) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
OutlinedButton( OutlinedButton(
onClick = viewModel::testConnection, onClick = onTest,
enabled = !state.isTesting, enabled = !isTesting,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) { ) {
if (state.isTesting) { if (isTesting) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.size(18.dp), modifier = Modifier.size(18.dp),
strokeWidth = 2.dp, strokeWidth = 2.dp,
@@ -375,7 +561,7 @@ fun SettingsScreen(
} }
Button( Button(
onClick = viewModel::save, onClick = onSave,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
colors = colors =
ButtonDefaults.buttonColors( ButtonDefaults.buttonColors(
@@ -385,11 +571,13 @@ fun SettingsScreen(
Text(stringResource(R.string.action_save)) Text(stringResource(R.string.action_save))
} }
} }
}
Spacer(modifier = Modifier.height(16.dp)) @Composable
} private fun BehaviorSection(
SettingsTab.BEHAVIOR -> { autoReconnect: Boolean,
// Behavior section onAutoReconnectChange: (Boolean) -> Unit,
) {
SectionCard(title = stringResource(R.string.settings_sec_behavior)) { SectionCard(title = stringResource(R.string.settings_sec_behavior)) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -410,8 +598,8 @@ fun SettingsScreen(
) )
} }
Switch( Switch(
checked = state.autoReconnect, checked = autoReconnect,
onCheckedChange = viewModel::onAutoReconnectChange, onCheckedChange = onAutoReconnectChange,
colors = colors =
SwitchDefaults.colors( SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.primary, checkedThumbColor = MaterialTheme.colorScheme.primary,
@@ -420,9 +608,17 @@ fun SettingsScreen(
) )
} }
} }
} }
SettingsTab.APPEARANCE -> {
// Appearance section @Composable
private fun AppearanceSection(
themePreference: ThemePreference,
onThemeChange: (ThemePreference) -> Unit,
useDynamicColors: Boolean,
onUseDynamicColorsChange: (Boolean) -> Unit,
themePreset: ThemePreset,
onThemePresetChange: (ThemePreset) -> Unit,
) {
SectionCard(title = stringResource(R.string.settings_sec_appearance)) { SectionCard(title = stringResource(R.string.settings_sec_appearance)) {
Text( Text(
text = stringResource(R.string.settings_item_theme), text = stringResource(R.string.settings_item_theme),
@@ -432,8 +628,8 @@ fun SettingsScreen(
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
ThemePreference.entries.forEachIndexed { index, pref -> ThemePreference.entries.forEachIndexed { index, pref ->
SegmentedButton( SegmentedButton(
selected = state.themePreference == pref, selected = themePreference == pref,
onClick = { viewModel.onThemeChange(pref) }, onClick = { onThemeChange(pref) },
shape = shape =
SegmentedButtonDefaults.itemShape( SegmentedButtonDefaults.itemShape(
index = index, index = index,
@@ -472,8 +668,8 @@ fun SettingsScreen(
) )
} }
Switch( Switch(
checked = state.useDynamicColors, checked = useDynamicColors,
onCheckedChange = viewModel::onUseDynamicColorsChange, onCheckedChange = onUseDynamicColorsChange,
colors = colors =
SwitchDefaults.colors( SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.primary, checkedThumbColor = MaterialTheme.colorScheme.primary,
@@ -497,7 +693,7 @@ fun SettingsScreen(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Text( Text(
when (state.themePreset) { when (themePreset) {
ThemePreset.DEFAULT -> stringResource(R.string.theme_preset_default) ThemePreset.DEFAULT -> stringResource(R.string.theme_preset_default)
ThemePreset.MONOCHROME -> stringResource(R.string.theme_preset_monochrome) ThemePreset.MONOCHROME -> stringResource(R.string.theme_preset_monochrome)
ThemePreset.GRUVBOX -> stringResource(R.string.theme_preset_gruvbox) ThemePreset.GRUVBOX -> stringResource(R.string.theme_preset_gruvbox)
@@ -540,7 +736,7 @@ fun SettingsScreen(
) )
}, },
onClick = { onClick = {
viewModel.onThemePresetChange(preset) onThemePresetChange(preset)
presetsExpanded = false presetsExpanded = false
}, },
) )
@@ -548,8 +744,15 @@ fun SettingsScreen(
} }
} }
} }
}
// Chat section @Composable
private fun ChatSection(
typingEffectEnabled: Boolean,
onTypingEffectEnabledChange: (Boolean) -> Unit,
typingEffectDelayMs: Int,
onTypingEffectDelayMsChange: (Int) -> Unit,
) {
SectionCard(title = stringResource(R.string.settings_sec_chat)) { SectionCard(title = stringResource(R.string.settings_sec_chat)) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -570,8 +773,8 @@ fun SettingsScreen(
) )
} }
Switch( Switch(
checked = state.typingEffectEnabled, checked = typingEffectEnabled,
onCheckedChange = viewModel::onTypingEffectEnabledChange, onCheckedChange = onTypingEffectEnabledChange,
colors = colors =
SwitchDefaults.colors( SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.primary, checkedThumbColor = MaterialTheme.colorScheme.primary,
@@ -581,20 +784,20 @@ fun SettingsScreen(
} }
// Delay slider — only visible when effect is enabled // Delay slider — only visible when effect is enabled
AnimatedVisibility(visible = state.typingEffectEnabled) { AnimatedVisibility(visible = typingEffectEnabled) {
Column(modifier = Modifier.padding(top = 12.dp)) { Column(modifier = Modifier.padding(top = 12.dp)) {
Text( Text(
text = text =
stringResource( stringResource(
R.string.settings_item_typing_delay, R.string.settings_item_typing_delay,
state.typingEffectDelayMs, typingEffectDelayMs,
), ),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Slider( Slider(
value = state.typingEffectDelayMs.toFloat(), value = typingEffectDelayMs.toFloat(),
onValueChange = { viewModel.onTypingEffectDelayMsChange(it.toInt()) }, onValueChange = { onTypingEffectDelayMsChange(it.toInt()) },
valueRange = 10f..100f, valueRange = 10f..100f,
steps = 8, // 10, 20, 30, 40, 50, 60, 70, 80, 90, 100 steps = 8, // 10, 20, 30, 40, 50, 60, 70, 80, 90, 100
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -621,8 +824,18 @@ fun SettingsScreen(
} }
} }
} }
}
// Navigation Bar section @Composable
private fun NavBarSection(
bottomNavDisplayMode: BottomNavDisplayMode,
onBottomNavDisplayModeChange: (BottomNavDisplayMode) -> Unit,
selectedNavItems: List<String>,
availableNavItems: List<Pair<String, Int>>,
onReorderNavItems: (List<String>) -> Unit,
onRemoveNavItem: (String) -> Unit,
onAddNavItem: (String) -> Unit,
) {
SectionCard(title = stringResource(R.string.settings_sec_nav_bar)) { SectionCard(title = stringResource(R.string.settings_sec_nav_bar)) {
Text( Text(
text = stringResource(R.string.settings_item_bottom_nav_display_mode), text = stringResource(R.string.settings_item_bottom_nav_display_mode),
@@ -632,8 +845,8 @@ fun SettingsScreen(
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
BottomNavDisplayMode.entries.forEachIndexed { index, mode -> BottomNavDisplayMode.entries.forEachIndexed { index, mode ->
SegmentedButton( SegmentedButton(
selected = state.bottomNavDisplayMode == mode, selected = bottomNavDisplayMode == mode,
onClick = { viewModel.onBottomNavDisplayModeChange(mode) }, onClick = { onBottomNavDisplayModeChange(mode) },
shape = shape =
SegmentedButtonDefaults.itemShape( SegmentedButtonDefaults.itemShape(
index = index, index = index,
@@ -670,13 +883,13 @@ fun SettingsScreen(
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
text = stringResource(R.string.settings_item_nav_items, state.selectedNavItems.size), text = stringResource(R.string.settings_item_nav_items, selectedNavItems.size),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
state.selectedNavItems.forEachIndexed { index, name -> selectedNavItems.forEachIndexed { index, name ->
val labelRes = viewModel.availableNavItems.firstOrNull { it.first == name }?.second val labelRes = availableNavItems.firstOrNull { it.first == name }?.second
val label = if (labelRes != null) stringResource(labelRes) else name val label = if (labelRes != null) stringResource(labelRes) else name
Row( Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
@@ -691,11 +904,11 @@ fun SettingsScreen(
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
IconButton( IconButton(
onClick = { onClick = {
val list = state.selectedNavItems.toMutableList() val list = selectedNavItems.toMutableList()
val temp = list[index] val temp = list[index]
list[index] = list[index - 1] list[index] = list[index - 1]
list[index - 1] = temp list[index - 1] = temp
viewModel.reorderNavItems(list) onReorderNavItems(list)
}, },
enabled = index > 0, enabled = index > 0,
) { ) {
@@ -706,20 +919,20 @@ fun SettingsScreen(
} }
IconButton( IconButton(
onClick = { onClick = {
val list = state.selectedNavItems.toMutableList() val list = selectedNavItems.toMutableList()
val temp = list[index] val temp = list[index]
list[index] = list[index + 1] list[index] = list[index + 1]
list[index + 1] = temp list[index + 1] = temp
viewModel.reorderNavItems(list) onReorderNavItems(list)
}, },
enabled = index < state.selectedNavItems.lastIndex, enabled = index < selectedNavItems.lastIndex,
) { ) {
Icon( Icon(
Icons.Filled.KeyboardArrowDown, Icons.Filled.KeyboardArrowDown,
contentDescription = stringResource(R.string.content_desc_move_down), contentDescription = stringResource(R.string.content_desc_move_down),
) )
} }
IconButton(onClick = { viewModel.removeNavItem(name) }) { IconButton(onClick = { onRemoveNavItem(name) }) {
Icon( Icon(
Icons.Filled.Close, Icons.Filled.Close,
contentDescription = contentDescription =
@@ -733,9 +946,9 @@ fun SettingsScreen(
} }
} }
if (state.selectedNavItems.size < 5) { if (selectedNavItems.size < 5) {
val available = val available =
viewModel.availableNavItems.filter { it.first !in state.selectedNavItems } availableNavItems.filter { it.first !in selectedNavItems }
if (available.isNotEmpty()) { if (available.isNotEmpty()) {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
Box { Box {
@@ -749,7 +962,7 @@ fun SettingsScreen(
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(labelRes)) }, text = { Text(stringResource(labelRes)) },
onClick = { onClick = {
viewModel.addNavItem(name) onAddNavItem(name)
expanded = false expanded = false
}, },
) )
@@ -765,9 +978,10 @@ fun SettingsScreen(
) )
} }
} }
} }
SettingsTab.ABOUT -> {
// ── About section ───────────────────────────────────────── @Composable
private fun AboutSection(onLogout: () -> Unit) {
SectionCard(title = stringResource(R.string.settings_sec_about)) { SectionCard(title = stringResource(R.string.settings_sec_about)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
@@ -817,10 +1031,7 @@ fun SettingsScreen(
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Button( Button(
onClick = { onClick = onLogout,
viewModel.logout()
onLogout()
},
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = colors =
ButtonDefaults.buttonColors( ButtonDefaults.buttonColors(
@@ -830,59 +1041,4 @@ fun SettingsScreen(
Text(stringResource(R.string.settings_logout)) Text(stringResource(R.string.settings_logout))
} }
} }
}
}
}
}
}
}
@Composable
private fun InfoRow(
label: String,
value: String,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
)
}
}
@Composable
private fun SectionCard(
title: String,
content: @Composable () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(1.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = title,
style =
MaterialTheme.typography.titleSmall.copy(
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
),
modifier = Modifier.padding(bottom = 12.dp),
)
content()
}
}
} }