refactor(sessions): decompose SessionsScreen into focused components
Extract SessionCard, SearchResultCard, SessionActionMenu, SessionsStatsRow, SessionsBulkActionBar, SessionsDialogs, and AutomationGroupList.
This commit is contained in:
1 parent
35dc778702
commit
3d711c5a51
8 files changed
+1098
-641
No files matched your search
@@ -116,6 +116,12 @@ import com.m57.hermescontrol.ui.common.StatusBadge
|
|||||||
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
||||||
import com.m57.hermescontrol.ui.common.ToastEffect
|
import com.m57.hermescontrol.ui.common.ToastEffect
|
||||||
import com.m57.hermescontrol.ui.common.listItemSpacing
|
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
|
* Auto-load the next history page when the user scrolls to within this many
|
||||||
@@ -680,65 +686,14 @@ fun SessionsScreen(
|
|||||||
else -> {
|
else -> {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
// ── Stats row ───────────────────────────────────────
|
// ── Stats row ───────────────────────────────────────
|
||||||
Row(
|
SessionsStatsRow(
|
||||||
modifier =
|
total = state.total,
|
||||||
Modifier
|
loadedMessageCount = loadedMessageCount,
|
||||||
.fillMaxWidth()
|
section = state.section,
|
||||||
.height(IntrinsicSize.Max)
|
spacing = spacing,
|
||||||
.padding(horizontal = spacing.md, vertical = spacing.sm),
|
statusColors = statusColors,
|
||||||
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
onPruneClick = { viewModel.showPruneDialog() },
|
||||||
) {
|
)
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Session list ────────────────────────────────────
|
// ── Session list ────────────────────────────────────
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
@@ -822,66 +777,21 @@ fun SessionsScreen(
|
|||||||
verticalArrangement = listItemSpacing,
|
verticalArrangement = listItemSpacing,
|
||||||
) {
|
) {
|
||||||
if (state.section == HistorySection.AUTOMATIONS) {
|
if (state.section == HistorySection.AUTOMATIONS) {
|
||||||
automationSessionGroups.forEach { group ->
|
automationGroups(
|
||||||
item(key = "automation-group:${group.key}") {
|
groups = automationSessionGroups,
|
||||||
val expanded = group.key in expandedAutomationGroups
|
expandedGroups = expandedAutomationGroups,
|
||||||
Row(
|
sessionItemsById = sessionItemsById,
|
||||||
modifier =
|
spacing = spacing,
|
||||||
Modifier
|
onToggleGroup = { groupKey ->
|
||||||
.fillMaxWidth()
|
expandedAutomationGroups =
|
||||||
.clickable {
|
if (groupKey in expandedAutomationGroups) {
|
||||||
expandedAutomationGroups =
|
expandedAutomationGroups - groupKey
|
||||||
if (expanded) {
|
} else {
|
||||||
expandedAutomationGroups - group.key
|
expandedAutomationGroups + groupKey
|
||||||
} 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,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
renderSessionCard = sessionCard,
|
||||||
if (group.key in expandedAutomationGroups) {
|
)
|
||||||
items(group.sessions, key = { it.id }) { session ->
|
|
||||||
sessionItemsById[session.id]?.let { sessionCard(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
items(sessionsToDisplay, key = { it.session.id }) { item ->
|
items(sessionsToDisplay, key = { it.session.id }) { item ->
|
||||||
sessionCard(item)
|
sessionCard(item)
|
||||||
@@ -927,533 +837,21 @@ fun SessionsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Bulk action toolbar (animated) ──────────────────────────────────
|
// ── Bulk action toolbar (animated) ──────────────────────────────────
|
||||||
AnimatedVisibility(
|
SessionsBulkActionBar(
|
||||||
visible = state.isSelecting && hasSelection,
|
visible = state.isSelecting && hasSelection,
|
||||||
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(),
|
allVisibleSessionsSelected = allVisibleSessionsSelected,
|
||||||
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(),
|
selectedCount = state.selectedIds.size,
|
||||||
modifier = Modifier.fillMaxSize(),
|
isDeletingBulk = state.isDeletingBulk,
|
||||||
) {
|
spacing = spacing,
|
||||||
Box(
|
statusColors = statusColors,
|
||||||
modifier = Modifier.fillMaxSize(),
|
onSelectAllToggle = {
|
||||||
contentAlignment = Alignment.BottomCenter,
|
if (allVisibleSessionsSelected) {
|
||||||
) {
|
viewModel.clearSelection()
|
||||||
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)
|
|
||||||
} else {
|
} else {
|
||||||
Icon(
|
viewModel.selectAll(visibleSessionIds)
|
||||||
imageVector = Icons.Filled.Delete,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = statusColors.error,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = !isDeleting,
|
onDeleteSelected = { viewModel.requestBulkDelete() },
|
||||||
onClick = onDelete,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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<AutomationSessionGroup>,
|
||||||
|
expandedGroups: Set<String>,
|
||||||
|
sessionItemsById: Map<String, SessionTreeItem>,
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+127
@@ -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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user