diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt index bd912149..0de2a4cb 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt @@ -116,6 +116,12 @@ import com.m57.hermescontrol.ui.common.StatusBadge import com.m57.hermescontrol.ui.common.StatusBadgeType import com.m57.hermescontrol.ui.common.ToastEffect import com.m57.hermescontrol.ui.common.listItemSpacing +import com.m57.hermescontrol.ui.sessions.components.SearchResultCard +import com.m57.hermescontrol.ui.sessions.components.SessionCard +import com.m57.hermescontrol.ui.sessions.components.SessionsBulkActionBar +import com.m57.hermescontrol.ui.sessions.components.SessionsDialogs +import com.m57.hermescontrol.ui.sessions.components.SessionsStatsRow +import com.m57.hermescontrol.ui.sessions.components.automationGroups /** * Auto-load the next history page when the user scrolls to within this many @@ -680,65 +686,14 @@ fun SessionsScreen( else -> { Column(modifier = Modifier.fillMaxSize()) { // ── Stats row ─────────────────────────────────────── - Row( - modifier = - Modifier - .fillMaxWidth() - .height(IntrinsicSize.Max) - .padding(horizontal = spacing.md, vertical = spacing.sm), - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - StatCard( - label = stringResource(R.string.sessions_stat_total), - value = formatCompactCount(state.total), - icon = Icons.Filled.History, - modifier = Modifier.weight(1f).fillMaxHeight(), - ) - StatCard( - label = stringResource(R.string.sessions_stat_messages_loaded), - value = formatCompactCount(loadedMessageCount), - icon = Icons.Filled.Email, - modifier = Modifier.weight(1f).fillMaxHeight(), - ) - // Prune button — compact card sized to its label, so the - // stat cards keep enough width for large counts. - if (state.section == HistorySection.CONVERSATIONS) { - Card( - modifier = Modifier.fillMaxHeight(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - onClick = { viewModel.showPruneDialog() }, - ) { - Box( - // NOTE: fillMaxHeight only — never fillMaxSize here. - // An unweighted Row child with fillMaxSize expands to the - // full row width and starves the weighted stat cards. - modifier = Modifier.fillMaxHeight().padding(horizontal = spacing.md), - contentAlignment = Alignment.Center, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(spacing.xs), - ) { - Icon( - imageVector = Icons.Filled.DeleteSweep, - contentDescription = null, - tint = statusColors.warning, - modifier = Modifier.size(20.dp), - ) - Text( - text = stringResource(R.string.sessions_action_prune), - style = MaterialTheme.typography.labelMedium, - color = statusColors.warning, - fontWeight = FontWeight.SemiBold, - ) - } - } - } - } - } + SessionsStatsRow( + total = state.total, + loadedMessageCount = loadedMessageCount, + section = state.section, + spacing = spacing, + statusColors = statusColors, + onPruneClick = { viewModel.showPruneDialog() }, + ) // ── Session list ──────────────────────────────────── val listState = rememberLazyListState() @@ -822,66 +777,21 @@ fun SessionsScreen( verticalArrangement = listItemSpacing, ) { if (state.section == HistorySection.AUTOMATIONS) { - automationSessionGroups.forEach { group -> - item(key = "automation-group:${group.key}") { - val expanded = group.key in expandedAutomationGroups - Row( - modifier = - Modifier - .fillMaxWidth() - .clickable { - expandedAutomationGroups = - if (expanded) { - expandedAutomationGroups - group.key - } else { - expandedAutomationGroups + group.key - } - }.padding(horizontal = spacing.sm, vertical = spacing.md), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = - if (expanded) { - Icons.Filled.KeyboardArrowDown - } else { - Icons.AutoMirrored.Filled.KeyboardArrowRight - }, - contentDescription = null, - ) - Spacer(modifier = Modifier.width(spacing.sm)) - Column { - Text( - text = - group.title - ?: group.jobId?.let { - stringResource( - R.string.sessions_section_automation_job, - it, - ) - } ?: stringResource( - R.string.sessions_section_automation_unknown, - ), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = - stringResource( - R.string.sessions_section_automation_runs, - group.sessions.size, - ), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + automationGroups( + groups = automationSessionGroups, + expandedGroups = expandedAutomationGroups, + sessionItemsById = sessionItemsById, + spacing = spacing, + onToggleGroup = { groupKey -> + expandedAutomationGroups = + if (groupKey in expandedAutomationGroups) { + expandedAutomationGroups - groupKey + } else { + expandedAutomationGroups + groupKey } - } - } - if (group.key in expandedAutomationGroups) { - items(group.sessions, key = { it.id }) { session -> - sessionItemsById[session.id]?.let { sessionCard(it) } - } - } - } + }, + renderSessionCard = sessionCard, + ) } else { items(sessionsToDisplay, key = { it.session.id }) { item -> sessionCard(item) @@ -927,533 +837,21 @@ fun SessionsScreen( } // ── Bulk action toolbar (animated) ────────────────────────────────── - AnimatedVisibility( + SessionsBulkActionBar( visible = state.isSelecting && hasSelection, - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(), - modifier = Modifier.fillMaxSize(), - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.BottomCenter, - ) { - Surface( - modifier = Modifier.fillMaxWidth(), - shadowElevation = 8.dp, - tonalElevation = 4.dp, - color = MaterialTheme.colorScheme.surfaceContainerHigh, - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = spacing.md, vertical = spacing.sm), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically, - ) { - // Select all / deselect - OutlinedButton( - onClick = { - if (allVisibleSessionsSelected) { - viewModel.clearSelection() - } else { - viewModel.selectAll(visibleSessionIds) - } - }, - ) { - Icon( - imageVector = - if (allVisibleSessionsSelected) { - Icons.Filled.Close - } else { - Icons.Filled.SelectAll - }, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(spacing.xs)) - Text( - if (allVisibleSessionsSelected) { - stringResource(R.string.sessions_action_deselect_all) - } else { - stringResource(R.string.sessions_action_select_all) - }, - ) - } - - // Delete selected - Button( - onClick = { viewModel.requestBulkDelete() }, - enabled = !state.isDeletingBulk, - colors = - ButtonDefaults.buttonColors( - containerColor = statusColors.error, - ), - ) { - if (state.isDeletingBulk) { - CircularProgressIndicator( - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp, - color = statusColors.onError, - ) - } else { - Icon( - imageVector = Icons.Filled.Delete, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - } - Spacer(modifier = Modifier.width(spacing.xs)) - Text( - stringResource( - R.string.sessions_action_delete_n, - state.selectedIds.size, - ), - ) - } - } - } - } - } - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun SessionCard( - session: com.m57.hermescontrol.data.model.SessionInfo, - displayTitle: String, - branchStem: String?, - isFork: Boolean = false, - forkDepth: Int = 0, - query: String, - isSelecting: Boolean, - isSelected: Boolean, - isDeleting: Boolean, - isPinned: Boolean, - isHidden: Boolean = false, - highlightBackground: Color, - highlightForeground: Color, - onCardClick: () -> Unit, - onToggleSelection: () -> Unit, - onSelect: () -> Unit, - onRename: () -> Unit, - onTogglePin: () -> Unit, - onToggleHide: () -> Unit, - onDelete: () -> Unit, -) { - val spacing = LocalSpacing.current - val statusColors = LocalHermesStatusColors.current - val isActive = session.status?.lowercase() == "active" || session.status?.lowercase() == "streaming" - val srcIcon = sourceIcon(session.source) - var menuExpanded by remember { mutableStateOf(false) } - - Card( - modifier = - Modifier - .fillMaxWidth() - .testTag("session_card_${session.id}") - .combinedClickable( - onClick = onCardClick, - // Long-press opens the per-session action menu (issue #785): - // Select / Rename / Delete. The old long-press behavior - // (jump straight into selection mode) is now the menu's - // "Select" item. - onLongClick = { if (!isSelecting) menuExpanded = true }, - ), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - border = - if (isActive && !isSelecting) { - BorderStroke(2.dp, statusColors.success) - } else if (isSelected) { - BorderStroke(2.dp, MaterialTheme.colorScheme.primary) - } else { - null - }, - ) { - Box { - Row( - modifier = Modifier.padding(spacing.md), - verticalAlignment = Alignment.CenterVertically, - ) { - // Checkbox in select mode - if (isSelecting) { - Checkbox( - checked = isSelected, - onCheckedChange = { onToggleSelection() }, - modifier = Modifier.testTag("session_checkbox_${session.id}"), - ) - Spacer(modifier = Modifier.width(spacing.sm)) - } - - // Fork marker: a fork icon + its nesting depth, so a chain reads - // 🍴1 → 🍴2 → 🍴3 and a branch-off pops out. No indentation — the - // number carries the structure so names never get squished. - if (isFork && !isSelecting) { - Icon( - imageVector = Icons.AutoMirrored.Filled.CallSplit, - contentDescription = stringResource(R.string.sessions_fork_indicator), - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(16.dp), - ) - if (forkDepth > 0) { - Spacer(modifier = Modifier.width(spacing.xs)) - Text( - text = forkDepth.toString(), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - ) - } - Spacer(modifier = Modifier.width(spacing.sm)) - } - - // Source icon - if (srcIcon != null && !isSelecting) { - Icon( - imageVector = srcIcon, - contentDescription = sourceLabel(session.source), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(spacing.sm)) - } - - // Main content - Column(modifier = Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = - if (query.isNotBlank()) { - highlightText(displayTitle, query, highlightBackground, highlightForeground) - } else { - AnnotatedString(displayTitle) - }, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - if (isPinned) { - Spacer(modifier = Modifier.width(spacing.xs)) - Icon( - imageVector = Icons.Filled.PushPin, - contentDescription = stringResource(R.string.sessions_pinned_indicator), - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(14.dp), - ) - } - } - - Spacer(modifier = Modifier.height(spacing.xs)) - - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.history_message_count, session.message_count ?: 0), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - if (session.hidden == true) { - Spacer(modifier = Modifier.width(spacing.sm)) - StatusBadge( - text = stringResource(R.string.sessions_hidden_badge), - status = StatusBadgeType.NEUTRAL, - ) - } - if (!session.status.isNullOrBlank()) { - Spacer(modifier = Modifier.width(spacing.sm)) - StatusBadge( - text = session.status, - status = if (isActive) StatusBadgeType.SUCCESS else StatusBadgeType.NEUTRAL, - ) - } - } - } - } - - // Per-session action menu (hold the card to open) - SessionActionMenu( - expanded = menuExpanded, - onDismiss = { menuExpanded = false }, - isDeleting = isDeleting, - isPinned = isPinned, - onTogglePin = { - menuExpanded = false - onTogglePin() - }, - isHidden = isHidden, - onToggleHide = { - menuExpanded = false - onToggleHide() - }, - onSelect = { - menuExpanded = false - onSelect() - }, - onRename = { - menuExpanded = false - onRename() - }, - onDelete = { - menuExpanded = false - onDelete() - }, - ) - } - } -} - -/** - * Search-result card. The backend search payload has no session title, so this card is - * honest about it: it shows a "Match" label + the highlighted snippet as the body, plus - * source / model / played-at metadata chips. It never presents the snippet as a name. - */ -@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class) -@Composable -private fun SearchResultCard( - session: com.m57.hermescontrol.data.model.SessionInfo, - query: String, - isSelecting: Boolean, - isSelected: Boolean, - isDeleting: Boolean, - highlightBackground: Color, - highlightForeground: Color, - onCardClick: () -> Unit, - onToggleSelection: () -> Unit, - onSelect: () -> Unit, - onRename: () -> Unit, - onDelete: () -> Unit, -) { - val spacing = LocalSpacing.current - val snippet = session.preview?.takeIf { it.isNotBlank() } ?: stringResource(R.string.history_untitled) - val cleanSnippet = cleanSearchSnippet(snippet) - val playedAt = formatPlayedAt(session.started_at) - var menuExpanded by remember { mutableStateOf(false) } - - Card( - modifier = - Modifier - .fillMaxWidth() - .testTag("session_card_${session.id}") - .combinedClickable( - onClick = onCardClick, - // Long-press opens the per-session action menu (issue #785) - onLongClick = { if (!isSelecting) menuExpanded = true }, - ), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - border = - if (isSelected) { - BorderStroke(2.dp, MaterialTheme.colorScheme.primary) - } else { - null - }, - ) { - Box { - Row( - modifier = Modifier.padding(spacing.sm), - verticalAlignment = Alignment.Top, - ) { - // Checkbox in select mode - if (isSelecting) { - Checkbox( - checked = isSelected, - onCheckedChange = { onToggleSelection() }, - modifier = Modifier.testTag("session_checkbox_${session.id}"), - ) - Spacer(modifier = Modifier.width(spacing.sm)) - } - - Column(modifier = Modifier.weight(1f)) { - // Header row: "Match" label + source icon - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(spacing.xs), - ) { - val srcIcon = sourceIcon(session.source) - if (srcIcon != null && !isSelecting) { - Icon( - imageVector = srcIcon, - contentDescription = sourceLabel(session.source), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(16.dp), - ) - } - Text( - text = stringResource(R.string.sessions_search_match_label), - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - letterSpacing = 0.5.sp, - ) - if (!session.id.isNullOrBlank()) { - Text( - text = "· ${session.id.take(8)}", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - Spacer(modifier = Modifier.height(spacing.xs)) - - // The matched snippet, highlighted — shown as the body, NOT as a title. - Text( - text = highlightText(cleanSnippet, query, highlightBackground, highlightForeground), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 4, - overflow = TextOverflow.Ellipsis, - ) - - Spacer(modifier = Modifier.height(spacing.xs)) - - // Metadata chips row - FlowRow( - horizontalArrangement = Arrangement.spacedBy(spacing.xs), - verticalArrangement = Arrangement.spacedBy(spacing.xs), - ) { - session.source?.let { src -> - StatusBadge( - text = sourceLabel(src), - status = StatusBadgeType.NEUTRAL, - ) - } - session.model?.let { mdl -> - StatusBadge( - text = stringResource(R.string.sessions_search_model_label) + ": " + mdl, - status = StatusBadgeType.INFO, - ) - } - playedAt?.let { time -> - StatusBadge( - text = stringResource(R.string.sessions_search_played_at, time), - status = StatusBadgeType.NEUTRAL, - ) - } - } - } - } - - // Per-session action menu (hold the card to open) - SessionActionMenu( - expanded = menuExpanded, - onDismiss = { menuExpanded = false }, - isDeleting = isDeleting, - onSelect = { - menuExpanded = false - onSelect() - }, - onRename = { - menuExpanded = false - onRename() - }, - onDelete = { - menuExpanded = false - onDelete() - }, - ) - } - } -} - -/** - * Per-session hold menu (issue #785): Pin/Unpin / Select / Rename / Delete. - * Opened by long-pressing a session card. The pin item is only shown when - * [onTogglePin] is provided (history cards; search-result cards omit it). - */ -@Composable -private fun SessionActionMenu( - expanded: Boolean, - onDismiss: () -> Unit, - isDeleting: Boolean, - isPinned: Boolean = false, - onTogglePin: (() -> Unit)? = null, - isHidden: Boolean = false, - onToggleHide: (() -> Unit)? = null, - onSelect: () -> Unit, - onRename: () -> Unit, - onDelete: () -> Unit, -) { - val statusColors = LocalHermesStatusColors.current - DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { - if (onTogglePin != null) { - DropdownMenuItem( - text = { - Text( - stringResource( - if (isPinned) { - R.string.sessions_action_unpin - } else { - R.string.sessions_action_pin - }, - ), - ) - }, - leadingIcon = { Icon(Icons.Filled.PushPin, contentDescription = null) }, - onClick = onTogglePin, - ) - } - if (onToggleHide != null) { - DropdownMenuItem( - text = { - Text( - stringResource( - if (isHidden) { - R.string.sessions_action_unhide - } else { - R.string.sessions_action_hide - }, - ), - ) - }, - leadingIcon = { - Icon( - imageVector = - if (isHidden) { - Icons.Filled.Visibility - } else { - Icons.Filled.VisibilityOff - }, - contentDescription = null, - ) - }, - onClick = onToggleHide, - ) - } - DropdownMenuItem( - text = { Text(stringResource(R.string.sessions_action_select)) }, - leadingIcon = { Icon(Icons.Filled.CheckCircle, contentDescription = null) }, - onClick = onSelect, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.sessions_action_rename)) }, - leadingIcon = { Icon(Icons.Filled.Edit, contentDescription = null) }, - onClick = onRename, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.action_delete)) }, - leadingIcon = { - if (isDeleting) { - CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + allVisibleSessionsSelected = allVisibleSessionsSelected, + selectedCount = state.selectedIds.size, + isDeletingBulk = state.isDeletingBulk, + spacing = spacing, + statusColors = statusColors, + onSelectAllToggle = { + if (allVisibleSessionsSelected) { + viewModel.clearSelection() } else { - Icon( - imageVector = Icons.Filled.Delete, - contentDescription = null, - tint = statusColors.error, - ) + viewModel.selectAll(visibleSessionIds) } }, - enabled = !isDeleting, - onClick = onDelete, + onDeleteSelected = { viewModel.requestBulkDelete() }, ) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/AutomationGroupList.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/AutomationGroupList.kt new file mode 100644 index 00000000..bc49534e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/AutomationGroupList.kt @@ -0,0 +1,90 @@ +package com.m57.hermescontrol.ui.sessions.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.SessionTreeItem +import com.m57.hermescontrol.theme.Spacing +import com.m57.hermescontrol.ui.sessions.AutomationSessionGroup + +internal fun LazyListScope.automationGroups( + groups: List, + expandedGroups: Set, + sessionItemsById: Map, + spacing: Spacing, + onToggleGroup: (String) -> Unit, + renderSessionCard: @Composable (SessionTreeItem) -> Unit, +) { + groups.forEach { group -> + item(key = "automation-group:${group.key}") { + val expanded = group.key in expandedGroups + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { onToggleGroup(group.key) } + .padding(horizontal = spacing.sm, vertical = spacing.md), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = + if (expanded) { + Icons.Filled.KeyboardArrowDown + } else { + Icons.AutoMirrored.Filled.KeyboardArrowRight + }, + contentDescription = null, + ) + Spacer(modifier = Modifier.width(spacing.sm)) + Column { + Text( + text = + group.title + ?: group.jobId?.let { + stringResource( + R.string.sessions_section_automation_job, + it, + ) + } ?: stringResource( + R.string.sessions_section_automation_unknown, + ), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = + stringResource( + R.string.sessions_section_automation_runs, + group.sessions.size, + ), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + if (group.key in expandedGroups) { + items(group.sessions, key = { it.id }) { session -> + sessionItemsById[session.id]?.let { renderSessionCard(it) } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SearchResultCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SearchResultCard.kt new file mode 100644 index 00000000..054703d4 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SearchResultCard.kt @@ -0,0 +1,198 @@ +package com.m57.hermescontrol.ui.sessions.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.SessionInfo +import com.m57.hermescontrol.theme.LocalSpacing +import com.m57.hermescontrol.ui.common.StatusBadge +import com.m57.hermescontrol.ui.common.StatusBadgeType +import com.m57.hermescontrol.ui.sessions.cleanSearchSnippet +import com.m57.hermescontrol.ui.sessions.formatPlayedAt + +/** + * Search-result card. The backend search payload has no session title, so this card is + * honest about it: it shows a "Match" label + the highlighted snippet as the body, plus + * source / model / played-at metadata chips. It never presents the snippet as a name. + */ +@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class) +@Composable +fun SearchResultCard( + session: SessionInfo, + query: String, + isSelecting: Boolean, + isSelected: Boolean, + isDeleting: Boolean, + highlightBackground: Color, + highlightForeground: Color, + onCardClick: () -> Unit, + onToggleSelection: () -> Unit, + onSelect: () -> Unit, + onRename: () -> Unit, + onDelete: () -> Unit, +) { + val spacing = LocalSpacing.current + val snippet = session.preview?.takeIf { it.isNotBlank() } ?: stringResource(R.string.history_untitled) + val cleanSnippet = cleanSearchSnippet(snippet) + val playedAt = formatPlayedAt(session.started_at) + var menuExpanded by remember { mutableStateOf(false) } + + Card( + modifier = + Modifier + .fillMaxWidth() + .testTag("session_card_${session.id}") + .combinedClickable( + onClick = onCardClick, + onLongClick = { if (!isSelecting) menuExpanded = true }, + ), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + border = + if (isSelected) { + BorderStroke(2.dp, MaterialTheme.colorScheme.primary) + } else { + null + }, + ) { + Box { + Row( + modifier = Modifier.padding(spacing.sm), + verticalAlignment = Alignment.Top, + ) { + if (isSelecting) { + Checkbox( + checked = isSelected, + onCheckedChange = { onToggleSelection() }, + modifier = Modifier.testTag("session_checkbox_${session.id}"), + ) + Spacer(modifier = Modifier.width(spacing.sm)) + } + + Column(modifier = Modifier.weight(1f)) { + // Header row: "Match" label + source icon + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing.xs), + ) { + val srcIcon = sourceIcon(session.source) + if (srcIcon != null && !isSelecting) { + Icon( + imageVector = srcIcon, + contentDescription = sourceLabel(session.source), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp), + ) + } + Text( + text = stringResource(R.string.sessions_search_match_label), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + letterSpacing = 0.5.sp, + ) + if (!session.id.isNullOrBlank()) { + Text( + text = "· ${session.id.take(8)}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(modifier = Modifier.height(spacing.xs)) + + // The matched snippet, highlighted — shown as the body, NOT as a title. + Text( + text = highlightText(cleanSnippet, query, highlightBackground, highlightForeground), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(modifier = Modifier.height(spacing.xs)) + + // Metadata chips row + FlowRow( + horizontalArrangement = Arrangement.spacedBy(spacing.xs), + verticalArrangement = Arrangement.spacedBy(spacing.xs), + ) { + session.source?.let { src -> + StatusBadge( + text = sourceLabel(src), + status = StatusBadgeType.NEUTRAL, + ) + } + session.model?.let { mdl -> + StatusBadge( + text = stringResource(R.string.sessions_search_model_label) + ": " + mdl, + status = StatusBadgeType.INFO, + ) + } + playedAt?.let { time -> + StatusBadge( + text = stringResource(R.string.sessions_search_played_at, time), + status = StatusBadgeType.NEUTRAL, + ) + } + } + } + } + + // Per-session action menu (hold the card to open) + SessionActionMenu( + expanded = menuExpanded, + onDismiss = { menuExpanded = false }, + isDeleting = isDeleting, + onSelect = { + menuExpanded = false + onSelect() + }, + onRename = { + menuExpanded = false + onRename() + }, + onDelete = { + menuExpanded = false + onDelete() + }, + ) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionActionMenu.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionActionMenu.kt new file mode 100644 index 00000000..23e0d37b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionActionMenu.kt @@ -0,0 +1,109 @@ +package com.m57.hermescontrol.ui.sessions.components + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.PushPin +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.LocalHermesStatusColors + +/** + * Per-session overflow/long-press menu. + */ +@Composable +fun SessionActionMenu( + expanded: Boolean, + onDismiss: () -> Unit, + isDeleting: Boolean, + isPinned: Boolean = false, + onTogglePin: (() -> Unit)? = null, + isHidden: Boolean = false, + onToggleHide: (() -> Unit)? = null, + onSelect: () -> Unit, + onRename: () -> Unit, + onDelete: () -> Unit, +) { + val statusColors = LocalHermesStatusColors.current + DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { + if (onTogglePin != null) { + DropdownMenuItem( + text = { + Text( + stringResource( + if (isPinned) { + R.string.sessions_action_unpin + } else { + R.string.sessions_action_pin + }, + ), + ) + }, + leadingIcon = { Icon(Icons.Filled.PushPin, contentDescription = null) }, + onClick = onTogglePin, + ) + } + if (onToggleHide != null) { + DropdownMenuItem( + text = { + Text( + stringResource( + if (isHidden) { + R.string.sessions_action_unhide + } else { + R.string.sessions_action_hide + }, + ), + ) + }, + leadingIcon = { + Icon( + imageVector = + if (isHidden) { + Icons.Filled.Visibility + } else { + Icons.Filled.VisibilityOff + }, + contentDescription = null, + ) + }, + onClick = onToggleHide, + ) + } + DropdownMenuItem( + text = { Text(stringResource(R.string.sessions_action_select)) }, + leadingIcon = { Icon(Icons.Filled.CheckCircle, contentDescription = null) }, + onClick = onSelect, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.sessions_action_rename)) }, + leadingIcon = { Icon(Icons.Filled.Edit, contentDescription = null) }, + onClick = onRename, + ) + DropdownMenuItem( + text = { + Text( + text = stringResource(R.string.action_delete), + color = statusColors.error, + ) + }, + leadingIcon = { + Icon( + Icons.Filled.Delete, + contentDescription = null, + tint = statusColors.error, + ) + }, + enabled = !isDeleting, + onClick = onDelete, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt new file mode 100644 index 00000000..9b574913 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt @@ -0,0 +1,285 @@ +package com.m57.hermescontrol.ui.sessions.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.CallSplit +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.PushPin +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.SessionInfo +import com.m57.hermescontrol.theme.LocalHermesStatusColors +import com.m57.hermescontrol.theme.LocalSpacing +import com.m57.hermescontrol.ui.common.StatusBadge +import com.m57.hermescontrol.ui.common.StatusBadgeType + +fun sourceIcon(source: String?): ImageVector? = + when (source?.lowercase()) { + "telegram", "tg" -> Icons.AutoMirrored.Filled.Send + "web", "dashboard" -> Icons.Filled.Language + "api", "rest" -> Icons.Filled.Code + "cli", "terminal" -> Icons.Filled.Terminal + else -> null + } + +fun sourceLabel(source: String?): String = + when (source?.lowercase()) { + "telegram", "tg" -> "Telegram" + "web", "dashboard" -> "Web" + "api", "rest" -> "API" + "cli", "terminal" -> "CLI" + else -> source ?: "Unknown" + } + +fun highlightText( + text: String, + query: String, + highlightBackground: Color, + highlightForeground: Color, +): AnnotatedString = + buildAnnotatedString { + if (query.isBlank()) { + append(text) + return@buildAnnotatedString + } + val lowerText = text.lowercase() + val lowerQuery = query.lowercase() + var currentIndex = 0 + while (currentIndex < text.length) { + val matchIndex = lowerText.indexOf(lowerQuery, currentIndex) + if (matchIndex == -1) { + append(text.substring(currentIndex)) + break + } + if (matchIndex > currentIndex) { + append(text.substring(currentIndex, matchIndex)) + } + withStyle( + SpanStyle( + background = highlightBackground, + color = highlightForeground, + fontWeight = FontWeight.Bold, + ), + ) { + append(text.substring(matchIndex, matchIndex + query.length)) + } + currentIndex = matchIndex + query.length + } + } + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun SessionCard( + session: SessionInfo, + displayTitle: String, + branchStem: String?, + isFork: Boolean = false, + forkDepth: Int = 0, + query: String, + isSelecting: Boolean, + isSelected: Boolean, + isDeleting: Boolean, + isPinned: Boolean, + isHidden: Boolean = false, + highlightBackground: Color, + highlightForeground: Color, + onCardClick: () -> Unit, + onToggleSelection: () -> Unit, + onSelect: () -> Unit, + onRename: () -> Unit, + onTogglePin: () -> Unit, + onToggleHide: () -> Unit, + onDelete: () -> Unit, +) { + val spacing = LocalSpacing.current + val statusColors = LocalHermesStatusColors.current + val isActive = session.status?.lowercase() == "active" || session.status?.lowercase() == "streaming" + val srcIcon = sourceIcon(session.source) + var menuExpanded by remember { mutableStateOf(false) } + + Card( + modifier = + Modifier + .fillMaxWidth() + .testTag("session_card_${session.id}") + .combinedClickable( + onClick = onCardClick, + onLongClick = { if (!isSelecting) menuExpanded = true }, + ), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + border = + if (isActive && !isSelecting) { + BorderStroke(2.dp, statusColors.success) + } else if (isSelected) { + BorderStroke(2.dp, MaterialTheme.colorScheme.primary) + } else { + null + }, + ) { + Box { + Row( + modifier = Modifier.padding(spacing.md), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isSelecting) { + Checkbox( + checked = isSelected, + onCheckedChange = { onToggleSelection() }, + modifier = Modifier.testTag("session_checkbox_${session.id}"), + ) + Spacer(modifier = Modifier.width(spacing.sm)) + } + + if (isFork && !isSelecting) { + Icon( + imageVector = Icons.AutoMirrored.Filled.CallSplit, + contentDescription = stringResource(R.string.sessions_fork_indicator), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp), + ) + if (forkDepth > 0) { + Spacer(modifier = Modifier.width(spacing.xs)) + Text( + text = forkDepth.toString(), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } + Spacer(modifier = Modifier.width(spacing.sm)) + } + + if (srcIcon != null && !isSelecting) { + Icon( + imageVector = srcIcon, + contentDescription = sourceLabel(session.source), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(spacing.sm)) + } + + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = + if (query.isNotBlank()) { + highlightText(displayTitle, query, highlightBackground, highlightForeground) + } else { + AnnotatedString(displayTitle) + }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (isPinned) { + Spacer(modifier = Modifier.width(spacing.xs)) + Icon( + imageVector = Icons.Filled.PushPin, + contentDescription = stringResource(R.string.sessions_pinned_indicator), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(14.dp), + ) + } + } + + Spacer(modifier = Modifier.height(spacing.xs)) + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.history_message_count, session.message_count ?: 0), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (session.hidden == true) { + Spacer(modifier = Modifier.width(spacing.sm)) + StatusBadge( + text = stringResource(R.string.sessions_hidden_badge), + status = StatusBadgeType.NEUTRAL, + ) + } + if (!session.status.isNullOrBlank()) { + Spacer(modifier = Modifier.width(spacing.sm)) + StatusBadge( + text = session.status, + status = if (isActive) StatusBadgeType.SUCCESS else StatusBadgeType.NEUTRAL, + ) + } + } + } + } + + SessionActionMenu( + expanded = menuExpanded, + onDismiss = { menuExpanded = false }, + isDeleting = isDeleting, + isPinned = isPinned, + onTogglePin = { + menuExpanded = false + onTogglePin() + }, + isHidden = isHidden, + onToggleHide = { + menuExpanded = false + onToggleHide() + }, + onSelect = { + menuExpanded = false + onSelect() + }, + onRename = { + menuExpanded = false + onRename() + }, + onDelete = { + menuExpanded = false + onDelete() + }, + ) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionsBulkActionBar.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionsBulkActionBar.kt new file mode 100644 index 00000000..f97bb25b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionsBulkActionBar.kt @@ -0,0 +1,127 @@ +package com.m57.hermescontrol.ui.sessions.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.SelectAll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.HermesStatusColors +import com.m57.hermescontrol.theme.Spacing + +@Composable +fun SessionsBulkActionBar( + visible: Boolean, + allVisibleSessionsSelected: Boolean, + selectedCount: Int, + isDeletingBulk: Boolean, + spacing: Spacing, + statusColors: HermesStatusColors, + onSelectAllToggle: () -> Unit, + onDeleteSelected: () -> Unit, +) { + AnimatedVisibility( + visible = visible, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(), + modifier = Modifier.fillMaxSize(), + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.BottomCenter, + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + shadowElevation = 8.dp, + tonalElevation = 4.dp, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = spacing.md, vertical = spacing.sm), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton(onClick = onSelectAllToggle) { + Icon( + imageVector = + if (allVisibleSessionsSelected) { + Icons.Filled.Close + } else { + Icons.Filled.SelectAll + }, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(spacing.xs)) + Text( + if (allVisibleSessionsSelected) { + stringResource(R.string.sessions_action_deselect_all) + } else { + stringResource(R.string.sessions_action_select_all) + }, + ) + } + + Button( + onClick = onDeleteSelected, + enabled = !isDeletingBulk, + colors = + ButtonDefaults.buttonColors( + containerColor = statusColors.error, + ), + ) { + if (isDeletingBulk) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = statusColors.onError, + ) + } else { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + } + Spacer(modifier = Modifier.width(spacing.xs)) + Text( + stringResource( + R.string.sessions_action_delete_n, + selectedCount, + ), + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionsDialogs.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionsDialogs.kt new file mode 100644 index 00000000..58cd4bc3 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionsDialogs.kt @@ -0,0 +1,152 @@ +package com.m57.hermescontrol.ui.sessions.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.HermesStatusColors +import com.m57.hermescontrol.theme.LocalSpacing +import com.m57.hermescontrol.ui.common.ConfirmDialog +import com.m57.hermescontrol.ui.sessions.SessionsUiState +import com.m57.hermescontrol.ui.sessions.cleanSearchSnippet + +@Composable +fun SessionsDialogs( + state: SessionsUiState, + pruneDays: String, + onPruneDaysChange: (String) -> Unit, + statusColors: HermesStatusColors, + onConfirmEmptyCleanup: () -> Unit, + onDismissEmptyCleanup: () -> Unit, + onConfirmPrune: (Int) -> Unit, + onDismissPrune: () -> Unit, + onConfirmDeleteSession: () -> Unit, + onDismissDeleteSession: () -> Unit, + onConfirmBulkDelete: () -> Unit, + onDismissBulkDelete: () -> Unit, + onRenameSession: (String, String) -> Unit, + onRenameDraftChange: (String) -> Unit, + onDismissRename: () -> Unit, +) { + val spacing = LocalSpacing.current + + // Empty-session cleanup dialog + if (state.showEmptyCleanupDialog) { + ConfirmDialog( + title = stringResource(R.string.sessions_empty_cleanup_title), + message = stringResource(R.string.sessions_empty_cleanup_message, state.emptyCount), + onConfirm = onConfirmEmptyCleanup, + onDismiss = onDismissEmptyCleanup, + confirmText = stringResource(R.string.sessions_empty_cleanup_confirm), + ) + } + + // Prune dialog + if (state.showPruneDialog) { + ConfirmDialog( + title = stringResource(R.string.sessions_prune_title), + message = stringResource(R.string.sessions_prune_desc), + onConfirm = { + val days = pruneDays.toIntOrNull() + if (days != null && days > 0) onConfirmPrune(days) + }, + onDismiss = onDismissPrune, + confirmText = stringResource(R.string.sessions_prune_confirm), + isDestructive = true, + extraContent = { + Spacer(modifier = Modifier.height(spacing.sm)) + OutlinedTextField( + value = pruneDays, + onValueChange = onPruneDaysChange, + label = { Text(stringResource(R.string.sessions_prune_days_label)) }, + placeholder = { Text("7") }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = + KeyboardActions( + onDone = { + val days = pruneDays.toIntOrNull() + if (days != null && days > 0) onConfirmPrune(days) + }, + ), + ) + }, + ) + } + + // Single-session delete confirmation dialog + if (state.sessionToDeleteConfirm != null) { + val sessionToDelete = state.sessionToDeleteConfirm + val sessionTitle = + state.sessions + .find { it.id == sessionToDelete } + ?.title + ?.takeIf { it.isNotBlank() } + ?: state.searchResults + .find { it.session_id == sessionToDelete } + ?.snippet + ?.let(::cleanSearchSnippet) + ?.take(80) + ?: stringResource(R.string.history_untitled) + + ConfirmDialog( + title = stringResource(R.string.sessions_delete_title), + message = stringResource(R.string.sessions_delete_message, sessionTitle), + onConfirm = onConfirmDeleteSession, + onDismiss = onDismissDeleteSession, + confirmText = stringResource(R.string.action_delete), + isDestructive = true, + ) + } + + // Bulk delete confirmation dialog + if (state.showBulkDeleteConfirm) { + ConfirmDialog( + title = stringResource(R.string.sessions_bulk_delete_title), + message = stringResource(R.string.sessions_bulk_delete_message, state.selectedIds.size), + onConfirm = onConfirmBulkDelete, + onDismiss = onDismissBulkDelete, + confirmText = stringResource(R.string.action_delete), + isDestructive = true, + ) + } + + // Single-session rename dialog + state.renamingSessionId?.let { sessionId -> + AlertDialog( + onDismissRequest = onDismissRename, + title = { Text(stringResource(R.string.sessions_rename_title)) }, + text = { + OutlinedTextField( + value = state.renameDraft, + onValueChange = onRenameDraftChange, + singleLine = true, + label = { Text(stringResource(R.string.sessions_rename_hint)) }, + ) + }, + confirmButton = { + TextButton( + onClick = { onRenameSession(sessionId, state.renameDraft) }, + enabled = state.renameDraft.isNotBlank(), + ) { + Text(stringResource(R.string.sessions_action_rename)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRename) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionsStatsRow.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionsStatsRow.kt new file mode 100644 index 00000000..4558dfd2 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionsStatsRow.kt @@ -0,0 +1,98 @@ +package com.m57.hermescontrol.ui.sessions.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DeleteSweep +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.History +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.HermesStatusColors +import com.m57.hermescontrol.theme.Spacing +import com.m57.hermescontrol.ui.common.StatCard +import com.m57.hermescontrol.ui.sessions.HistorySection +import com.m57.hermescontrol.ui.sessions.formatCompactCount + +@Composable +fun SessionsStatsRow( + total: Int, + loadedMessageCount: Int, + section: HistorySection, + spacing: Spacing, + statusColors: HermesStatusColors, + onPruneClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .height(IntrinsicSize.Max) + .padding(horizontal = spacing.md, vertical = spacing.sm), + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + StatCard( + label = stringResource(R.string.sessions_stat_total), + value = formatCompactCount(total), + icon = Icons.Filled.History, + modifier = Modifier.weight(1f).fillMaxHeight(), + ) + StatCard( + label = stringResource(R.string.sessions_stat_messages_loaded), + value = formatCompactCount(loadedMessageCount), + icon = Icons.Filled.Email, + modifier = Modifier.weight(1f).fillMaxHeight(), + ) + if (section == HistorySection.CONVERSATIONS) { + Card( + modifier = Modifier.fillMaxHeight(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + onClick = onPruneClick, + ) { + Box( + modifier = Modifier.fillMaxHeight().padding(horizontal = spacing.md), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(spacing.xs), + ) { + Icon( + imageVector = Icons.Filled.DeleteSweep, + contentDescription = null, + tint = statusColors.warning, + modifier = Modifier.size(20.dp), + ) + Text( + text = stringResource(R.string.sessions_action_prune), + style = MaterialTheme.typography.labelMedium, + color = statusColors.warning, + fontWeight = FontWeight.SemiBold, + ) + } + } + } + } + } +}