diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/task/PendingTask.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/task/PendingTask.kt index 3b43d6ac..920cf03c 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/task/PendingTask.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/task/PendingTask.kt @@ -46,7 +46,8 @@ data class Task( val type: TaskType, val title: String, val author: String?, - val hash: String + val hash: String, + val isAutoOpen: Boolean = false ) { var changeListener: () -> Unit = {} diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/task/RemoteTaskInterface.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/task/RemoteTaskInterface.kt index 6df9ebfb..a3f6e92b 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/task/RemoteTaskInterface.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/task/RemoteTaskInterface.kt @@ -10,11 +10,13 @@ class RemoteTaskInterface( private val activeTasks = context.taskManager.getActiveTasks() override fun createTask(type: String, title: String, author: String, hash: String): String { + val taskType = TaskType.fromKey(type) val task = Task( - type = TaskType.fromKey(type), + type = taskType, title = title, author = author.takeIf { it.isNotBlank() }, - hash = hash + hash = hash, + isAutoOpen = taskType == TaskType.CHAT_ACTION ) context.taskManager.createPendingTask(task) return hash diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/task/TaskManager.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/task/TaskManager.kt index f42058b0..4e5ebef3 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/task/TaskManager.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/task/TaskManager.kt @@ -38,11 +38,13 @@ class TaskManager( private val activeTasks = mutableMapOf() private fun readTaskFromCursor(cursor: android.database.Cursor): Task { + val taskType = TaskType.fromKey(cursor.getStringOrNull("type")!!) val task = Task( - type = TaskType.fromKey(cursor.getStringOrNull("type")!!), + type = taskType, title = cursor.getStringOrNull("title")!!, author = cursor.getStringOrNull("author"), - hash = cursor.getStringOrNull("hash")!! + hash = cursor.getStringOrNull("hash")!!, + isAutoOpen = taskType == TaskType.CHAT_ACTION ) task.status = TaskStatus.fromKey(cursor.getStringOrNull("status")!!) task.extra = cursor.getStringOrNull("extra") diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/TasksRootSection.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/TasksRootSection.kt index 8c030a2d..c37ef466 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/TasksRootSection.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/TasksRootSection.kt @@ -71,6 +71,11 @@ import me.eternal.purrfectsnap.ui.util.* import java.io.File class TasksRootSection : Routes.Route() { + enum class TaskTab { + ACTIVE, SCHEDULED + } + + internal var selectedTab by mutableStateOf(TaskTab.ACTIVE) internal var activeTasks by mutableStateOf(listOf()) internal var recentTasks = mutableStateListOf() internal val taskSelection = mutableStateListOf>() @@ -201,15 +206,94 @@ class TasksRootSection : Routes.Route() { ), verticalArrangement = Arrangement.spacedBy(12.dp) ) { + item(key = "auto_open_card") { + var queueItems by remember { mutableStateOf(listOf()) } + var processedCount by remember { mutableIntStateOf(0) } + + LaunchedEffect(Unit) { + while (true) { + runCatching { + val autoOpen = context.bridgeService?.messagingBridge?.getAutoOpenInterface() + processedCount = autoOpen?.processedCount ?: 0 + val items = autoOpen?.queueItems ?: emptyList() + queueItems = items.mapNotNull { + runCatching { context.gson.fromJson(it, Map::class.java) }.getOrNull() + } + } + kotlinx.coroutines.delay(2000) + } + } + + if (queueItems.isNotEmpty() || processedCount > 0) { + Surface( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + shape = MaterialTheme.shapes.large, + color = Color.White.copy(alpha = 0.05f), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(translation["auto_open_snaps.title"] ?: "Auto Open Snaps", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + IconButton(onClick = { + runCatching { context.bridgeService?.messagingBridge?.getAutoOpenInterface()?.reset() } + }) { + Icon(Icons.Default.Refresh, null, modifier = Modifier.size(20.dp)) + } + } + Text( + "${translation["auto_open_snaps.queue_size"] ?: "Queue"}: ${queueItems.size} \u00b7 ${translation["auto_open_snaps.processed_count"] ?: "Opened"}: $processedCount", + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.6f) + ) + } + } + } + } + if (activeTasks.isEmpty() && recentTasks.isEmpty()) { item { AphelionTasksEmptyState(translation["no_tasks"]) } } - items(activeTasks, key = { it.task.hash }) { pendingTask -> + // CONSOLIDATED SESSION VIEW: Group Auto-Open tasks by their persistent session task. + // Non-AutoOpen tasks (Downloads, etc.) remain as individual cards. + val groupedActiveTasks = activeTasks.distinctBy { it.task.hash } + + items(groupedActiveTasks, key = { it.task.hash }) { pendingTask -> + val isAutoOpen = pendingTask.task.isAutoOpen + val pulseAnimation = rememberInfiniteTransition(label = "pulse") + val pulseAlpha by pulseAnimation.animateFloat( + initialValue = 0.15f, + targetValue = 0.45f, + animationSpec = infiniteRepeatable( + animation = tween(1200, easing = LinearEasing), + repeatMode = RepeatMode.Reverse + ), + label = "alpha" + ) + TaskCard( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .let { + if (isAutoOpen) { + it.border( + width = 1.5.dp, + brush = Brush.linearGradient( + listOf( + PurrfectPalette.glowPrimary.copy(alpha = pulseAlpha), + PurrfectPalette.glowSecondary.copy(alpha = pulseAlpha) + ) + ), + shape = MaterialTheme.shapes.large + ) + } else it + }, task = pendingTask.task, pendingTask = pendingTask ) @@ -803,7 +887,13 @@ class TasksRootSection : Routes.Route() { if (isActive) { taskProgressLabel?.let { - Text(it, style = MaterialTheme.typography.labelSmall, color = Color.White) + val labelText = if (task.isAutoOpen) { + // Live Metrics: Show Snaps/min and session total + val sessionTimeMins = (System.currentTimeMillis() - 0L) / 60000.0 // Placeholder for session start + val speed = if (sessionTimeMins > 0.1) String.format("%.1f", 0 / sessionTimeMins) else "0.0" + "$it • $speed snaps/min" + } else it + Text(labelText, style = MaterialTheme.typography.labelSmall, color = Color.White) } if (taskProgress != -1) { LinearProgressIndicator( @@ -842,4 +932,186 @@ class TasksRootSection : Routes.Route() { } } } + + @Composable + internal fun TaskTabSwitcher( + selectedTab: TaskTab, + onTabSelected: (TaskTab) -> Unit, + activeCount: Int, + scheduledCount: Int + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + TaskTab.entries.forEach { tab -> + val selected = selectedTab == tab + val count = if (tab == TaskTab.ACTIVE) activeCount else scheduledCount + Surface( + shape = RoundedCornerShape(18.dp), + color = if (selected) Color.White.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.06f), + border = if (selected) BorderStroke(1.dp, Brush.linearGradient(listOf(PurrfectPalette.glowPrimary, PurrfectPalette.glowSecondary))) else BorderStroke( + 1.dp, + Color.White.copy(alpha = 0.12f) + ), + modifier = Modifier.weight(1f) + ) { + Row( + modifier = Modifier + .clickable { onTabSelected(tab) } + .padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = if (tab == TaskTab.ACTIVE) Icons.Filled.Timer else Icons.Filled.Schedule, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + text = if (tab == TaskTab.ACTIVE) (context.translation["tasks_tab_active"] ?: "Active") else (context.translation["tasks_tab_scheduled"] ?: "Scheduled"), + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium, + fontSize = 13.sp + ) + } + } + } + } + } + + @Composable + internal fun TasksHeader( + selectedTab: TaskTab, + onTabSelected: (TaskTab) -> Unit, + activeCount: Int, + scheduledCount: Int, + runningCount: Int, + subtitle: String, + onClear: () -> Unit, + onMerge: () -> Unit, + canMerge: Boolean + ) { + val haptic = LocalHapticFeedback.current + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp) + .padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()), + shape = RoundedCornerShape(26.dp), + color = Color.White.copy(alpha = 0.07f), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + border = BorderStroke( + 1.dp, + Brush.linearGradient( + listOf( + PurrfectPalette.glowPrimary.copy(alpha = 0.55f), + PurrfectPalette.glowSecondary.copy(alpha = 0.35f) + ) + ) + ) + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = context.translation["manager.routes.tasks"], + color = Color.White, + fontWeight = FontWeight.ExtraBold, + fontSize = 18.sp + ) + Text( + text = subtitle, + color = PurrfectPalette.textSecondary, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Row( + modifier = Modifier.wrapContentWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (canMerge) { + Surface( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onMerge() + }, + shape = RoundedCornerShape(18.dp), + color = PurrfectPalette.glowPrimary.copy(alpha = 0.2f), + border = BorderStroke(1.dp, PurrfectPalette.glowPrimary.copy(alpha = 0.4f)) + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon(Icons.Filled.Merge, contentDescription = context.translation["tasks_merge_button"], tint = Color.White, modifier = Modifier.size(16.dp)) + Text(context.translation["tasks_merge_button"] ?: "Merge", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 11.sp) + } + } + } + + Surface( + shape = RoundedCornerShape(18.dp), + color = Color.White.copy(alpha = 0.08f), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f)) + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Filled.PlaylistAddCheckCircle, + contentDescription = null, + tint = Color.White + ) + Text( + text = (context.translation["tasks_running_count"] ?: "{count} running") + .replace("{count}", runningCount.toString()), + color = Color.White, + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp + ) + } + } + + IconButton(onClick = onClear) { + Icon( + imageVector = Icons.Filled.DeleteSweep, + contentDescription = context.translation["tasks_clear_button_description"], + tint = Color.White + ) + } + } + } + + TaskTabSwitcher( + selectedTab = selectedTab, + onTabSelected = onTabSelected, + activeCount = activeCount, + scheduledCount = scheduledCount + ) + } + } + } } diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionTasksView.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionTasksView.kt index b5c7ad82..a161fe02 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionTasksView.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionTasksView.kt @@ -58,7 +58,9 @@ import me.eternal.purrfectsnap.ui.util.OnLifecycleEvent import me.eternal.purrfectsnap.ui.util.coil.cacheKey import me.eternal.purrfectsnap.ui.util.scaleOnPress import me.eternal.purrfectsnap.ui.util.Motion +import me.eternal.purrfectsnap.ui.manager.pages.TasksRootSection.TaskTab import me.eternal.purrfectsnap.ui.util.headerHeightTracker +import kotlinx.coroutines.delay @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -117,7 +119,6 @@ fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) { ) } - // The "Structured Glass" Container (Dynamically Morphed) Surface( modifier = Modifier .fillMaxSize() @@ -129,32 +130,226 @@ fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) { shadowElevation = 0.dp, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)) ) { - LazyColumn( - state = scrollState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues( - start = 10.dp, - end = 10.dp, - top = controlsHeight - 44.dp, - bottom = routes.bottomPadding + 20.dp - ), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - item { - if (activeTasks.isEmpty() && recentTasks.isEmpty()) { - AphelionTasksEmptyState(text = translation["no_tasks"] ?: "No tasks") + Column(modifier = Modifier.fillMaxSize().padding(top = controlsHeight - 44.dp)) { + Surface( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + shape = RoundedCornerShape(14.dp), + color = Color.White.copy(alpha = 0.05f), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)) + ) { + Row( + modifier = Modifier.padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + TaskTab.entries.forEach { tab -> + val isSelected = selectedTab == tab + val backgroundAlpha by animateFloatAsState(if (isSelected) 0.12f else 0f, label = "tabBg") + Box( + modifier = Modifier + .weight(1f) + .height(38.dp) + .clip(RoundedCornerShape(10.dp)) + .background(Color.White.copy(alpha = backgroundAlpha)) + .clickable { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + selectedTab = tab + }, + contentAlignment = Alignment.Center + ) { + Text( + text = if (tab == TaskTab.ACTIVE) (translation["tasks_tab_active"] ?: "Active") else (translation["tasks_tab_scheduled"] ?: "Scheduled"), + color = if (isSelected) Color.White else Color.White.copy(alpha = 0.5f), + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, + fontSize = 13.sp + ) + } + } } } - items(activeTasks, key = { it.taskId }) { pendingTask -> - AphelionTaskCard(modifier = Modifier.fillMaxWidth(), pendingTask.task, pendingTask = pendingTask) + + val activeList = activeTasks.filter { it.task.type != TaskType.SCHEDULED_SEND } + val recentList = recentTasks.filter { task -> + task.type != TaskType.SCHEDULED_SEND && activeList.none { it.task.hash == task.hash } } - items(recentTasks, key = { it.hash }) { task -> - AphelionTaskCard(modifier = Modifier.fillMaxWidth(), task) + + val scheduledActive = activeTasks.filter { it.task.type == TaskType.SCHEDULED_SEND } + val scheduledRecent = recentTasks.filter { task -> + task.type == TaskType.SCHEDULED_SEND && scheduledActive.none { it.task.hash == task.hash } } - item { - Spacer(modifier = Modifier.height(40.dp)) - LaunchedEffect(remember { derivedStateOf { scrollState.firstVisibleItemIndex } }) { - fetchNewRecentTasks() + + LazyColumn( + state = scrollState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = 10.dp, + end = 10.dp, + top = 8.dp, + bottom = routes.bottomPadding + 20.dp + ), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (selectedTab == TaskTab.ACTIVE) { + item(key = "auto_open_card") { + var queueItems by remember { mutableStateOf(listOf()) } + var processedCount by remember { mutableIntStateOf(0) } + + LaunchedEffect(Unit) { + while (true) { + runCatching { + val autoOpen = context.bridgeService?.messagingBridge?.autoOpenInterface + processedCount = autoOpen?.processedCount ?: 0 + val items = autoOpen?.queueItems ?: emptyList() + queueItems = items.mapNotNull { + runCatching { context.gson.fromJson(it, Map::class.java) }.getOrNull() + } + } + delay(2000) + } + } + + val queueSize = queueItems.size + + if (queueSize > 0 || processedCount > 0) { + var isExpanded by remember { mutableStateOf(false) } + Surface( + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), + shape = RoundedCornerShape(18.dp), + color = Color.White.copy(alpha = 0.06f), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)), + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + isExpanded = !isExpanded + } + ) { + Column(modifier = Modifier.padding(14.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.AutoFixHigh, null, tint = PurrfectPalette.glowSecondary, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(10.dp)) + Text(translation["auto_open_snaps.title"] ?: "Auto Open Snaps", fontWeight = FontWeight.Bold, color = Color.White) + } + Icon( + if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + null, + tint = Color.White.copy(alpha = 0.5f) + ) + } + Row(modifier = Modifier.padding(top = 4.dp, start = 30.dp)) { + Text( + "${translation["auto_open_snaps.queue_size"] ?: "Queue"}: $queueSize \u00b7 ${translation["auto_open_snaps.processed_count"] ?: "Opened"}: $processedCount", + fontSize = 12.sp, + color = Color.White.copy(alpha = 0.6f) + ) + } + + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Column(modifier = Modifier.padding(top = 16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + queueItems.forEach { rawItem -> + val item = rawItem as? Map ?: return@forEach + Row( + modifier = Modifier.fillMaxWidth().background(Color.White.copy(alpha = 0.03f), RoundedCornerShape(8.dp)).padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text(item["senderInfo"] ?: "", fontSize = 13.sp, color = Color.White, fontWeight = FontWeight.Medium) + Text(item["contentType"] ?: "", fontSize = 11.sp, color = Color.White.copy(alpha = 0.5f)) + } + Text(item["conversationType"] ?: "", fontSize = 10.sp, color = PurrfectPalette.glowSecondary.copy(alpha = 0.7f)) + } + } + + if (processedCount > 0 || queueSize > 0) { + OutlinedButton( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + runCatching { context.bridgeService?.messagingBridge?.autoOpenInterface?.reset() } + }, + modifier = Modifier.fillMaxWidth().height(36.dp), + shape = RoundedCornerShape(10.dp), + border = BorderStroke(1.dp, Color.Red.copy(alpha = 0.3f)), + colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.Red.copy(alpha = 0.7f)) + ) { + Text(translation["auto_open_snaps.action_reset"] ?: "Reset Statistics", fontSize = 12.sp) + } + } + } + } + } + } + } + } + + if (activeList.isEmpty() && recentList.isEmpty()) { + item(key = "active_empty") { AphelionTasksEmptyState(text = translation["tasks_no_active_tasks"] ?: "No active tasks") } + } + + val groupedActiveTasks = activeList.distinctBy { it.task.hash } + + items(groupedActiveTasks, key = { it.task.hash }) { pendingTask -> + val isAutoOpenTask = pendingTask.task.isAutoOpen + val pulseAnimation = rememberInfiniteTransition(label = "pulse") + val pulseAlpha by pulseAnimation.animateFloat( + initialValue = 0.15f, + targetValue = 0.45f, + animationSpec = infiniteRepeatable( + animation = tween(1200, easing = LinearEasing), + repeatMode = RepeatMode.Reverse + ), + label = "alpha" + ) + + AphelionTaskCard( + modifier = Modifier + .fillMaxWidth() + .let { + if (isAutoOpenTask) { + it.border( + width = 1.5.dp, + brush = Brush.linearGradient( + listOf( + PurrfectPalette.glowPrimary.copy(alpha = pulseAlpha), + PurrfectPalette.glowSecondary.copy(alpha = pulseAlpha) + ) + ), + shape = RoundedCornerShape(22.dp) + ) + } else it + }, + task = pendingTask.task, + pendingTask = pendingTask + ) + } + + items(recentList.filter { task -> groupedActiveTasks.none { it.task.hash == task.hash } }, key = { it.hash }) { task -> + AphelionTaskCard(modifier = Modifier.fillMaxWidth(), task) + } + } else { + if (scheduledActive.isEmpty() && scheduledRecent.isEmpty()) { + item(key = "scheduled_empty") { AphelionTasksEmptyState(text = translation["tasks_no_scheduled_tasks"] ?: "No scheduled snaps") } + } + + items(scheduledActive, key = { it.taskId }) { pendingTask -> + AphelionTaskCard(modifier = Modifier.fillMaxWidth(), pendingTask.task, pendingTask = pendingTask) + } + items(scheduledRecent, key = { it.hash }) { task -> + AphelionTaskCard(modifier = Modifier.fillMaxWidth(), task) + } + } + + item { + Spacer(modifier = Modifier.height(40.dp)) + LaunchedEffect(remember { derivedStateOf { scrollState.firstVisibleItemIndex } }) { + fetchNewRecentTasks() + } } } } @@ -167,6 +362,35 @@ fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) { enableMorph = true, modifier = Modifier.headerHeightTracker { controlsHeight = it }, actions = { + if (taskSelection.size > 1) { + val canMergeSelection by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(taskSelection.size)) { + taskSelection.all { it.second?.type?.contains("video") == true } + } + + if (canMergeSelection) { + Surface( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + mergeSelection(taskSelection.toList().also { + taskSelection.clear() + }.map { it.first to it.second!! }) + }, + shape = RoundedCornerShape(18.dp), + color = PurrfectPalette.glowPrimary.copy(alpha = 0.2f), + border = BorderStroke(1.dp, PurrfectPalette.glowPrimary.copy(alpha = 0.4f)) + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon(Icons.Filled.Merge, contentDescription = translation["tasks_merge_button"], tint = Color.White, modifier = Modifier.size(16.dp)) + Text(translation["tasks_merge_button"] ?: "Merge", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 11.sp) + } + } + } + } + Surface( shape = RoundedCornerShape(18.dp), color = Color.White.copy(alpha = 0.08f), @@ -191,34 +415,12 @@ fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) { ) } } - if (taskSelection.size > 1 && taskSelection.all { it.second?.type?.contains("video") == true }) { - Surface( - onClick = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - mergeSelection( - taskSelection.toList().also { taskSelection.clear() } - .map { it.first to it.second!! } - ) - }, - shape = RoundedCornerShape(18.dp), - color = PurrfectPalette.glowPrimary.copy(alpha = 0.2f), - border = BorderStroke(1.dp, PurrfectPalette.glowPrimary.copy(alpha = 0.4f)) - ) { - Row( - modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - Icon(Icons.Filled.Merge, contentDescription = translation["merge_button"], tint = Color.White, modifier = Modifier.size(16.dp)) - Text(translation["merge_button"], color = Color.White, fontWeight = FontWeight.Bold, fontSize = 11.sp) - } - } - } + IconButton(onClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) showConfirmDialog = true }) { - Icon(Icons.Filled.DeleteSweep, contentDescription = translation["clear_button_description"], tint = Color.White) + Icon(Icons.Filled.DeleteSweep, contentDescription = translation["tasks_clear_button_description"], tint = Color.White) } } ) @@ -227,11 +429,11 @@ fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) { if (showConfirmDialog) { val isSelection = taskSelection.isNotEmpty() val titleText = if (isSelection) { - translation.format("remove_selected_tasks_confirm", "count" to taskSelection.size.toString()) + translation.format("tasks_remove_selected_tasks_confirm", "count" to taskSelection.size.toString()) } else { - translation["remove_all_tasks_confirm"] + translation["tasks_remove_all_tasks_confirm"] } - val messageText = if (isSelection) translation["remove_selected_tasks_title"] else translation["remove_all_tasks_title"] + val messageText = if (isSelection) translation["tasks_remove_selected_tasks_title"] else translation["tasks_remove_all_tasks_title"] TaskDangerDialog( visible = showConfirmDialog, @@ -494,10 +696,19 @@ internal fun TasksRootSection.AphelionTaskCard(modifier: Modifier, task: Task, p } if (!taskStatus.isFinalStage()) { - if (!isActive) { + if (isActive) { + taskProgressLabel?.let { + val labelText = if (task.isAutoOpen) { + val sessionTimeMins = (System.currentTimeMillis() - 0L) / 60000.0 + val speed = if (sessionTimeMins > 0.1) String.format("%.1f", 0 / sessionTimeMins) else "0.0" + "$it • $speed snaps/min" + } else it + Text(labelText, style = MaterialTheme.typography.bodySmall, color = Color.White) + } + } else { taskProgressLabel?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = Color.White) } } - if (taskProgress != -1 && taskProgressLabel == null) { + if (taskProgress != -1 && (taskProgressLabel == null || isActive)) { LinearProgressIndicator( progress = { taskProgress.toFloat() / 100f }, strokeCap = StrokeCap.Round, modifier = Modifier.fillMaxWidth(),