* 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:
1 parent
a3b794be5c
commit
9297413b2a
2 files changed
+692
-443
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))
|
|
||||||
}
|
}
|
||||||
SettingsTab.BEHAVIOR -> {
|
|
||||||
// Behavior section
|
@Composable
|
||||||
|
private fun BehaviorSection(
|
||||||
|
autoReconnect: Boolean,
|
||||||
|
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,
|
||||||
@@ -421,8 +609,16 @@ 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
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -766,8 +979,9 @@ 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(
|
||||||
@@ -831,58 +1042,3 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user