* 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:
@@ -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,152 +244,37 @@ 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(
|
isDark = isDark,
|
||||||
items = state.messages,
|
listState = listState,
|
||||||
key = { _, message -> message.id },
|
lastAnimatedMessageId = lastAnimatedMessageId,
|
||||||
) { index, message ->
|
onLastAnimatedMessageIdChange = { lastAnimatedMessageId = it },
|
||||||
val isCurrentMatch =
|
viewModel = viewModel,
|
||||||
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,
|
|
||||||
onAnimationComplete = {
|
|
||||||
lastAnimatedMessageId = message.id
|
|
||||||
},
|
|
||||||
)
|
|
||||||
} 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(
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user