diff --git a/apps/mobile/android/app/src/main/java/com/devtodo/app/ui/screens/WorkflowsV2Screen.kt b/apps/mobile/android/app/src/main/java/com/devtodo/app/ui/screens/WorkflowsV2Screen.kt index e7dc0bf..7797b32 100644 --- a/apps/mobile/android/app/src/main/java/com/devtodo/app/ui/screens/WorkflowsV2Screen.kt +++ b/apps/mobile/android/app/src/main/java/com/devtodo/app/ui/screens/WorkflowsV2Screen.kt @@ -5,14 +5,19 @@ import androidx.compose.animation.* import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Surface + import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.AccountTree import androidx.compose.material.icons.filled.Add @@ -24,7 +29,6 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -36,7 +40,6 @@ import com.devtodo.app.data.local.WorkflowStageEntity import com.devtodo.app.data.local.WorkflowTaskMembershipEntity import com.devtodo.app.data.model.TaskStatus import com.devtodo.app.ui.components.* -import com.devtodo.app.ui.theme.LocalAdaptiveInfo import com.devtodo.app.ui.theme.TaskDockMotion import com.devtodo.app.ui.theme.TaskDockShapes @@ -69,386 +72,252 @@ fun WorkflowsV2Screen( var deleteWorkflow by remember { mutableStateOf(null) } var deleteStage by remember { mutableStateOf(null) } - val adaptive = LocalAdaptiveInfo.current var selectedId by rememberSaveable { mutableStateOf(null) } val selected = workflows.find { it.id == selectedId } - val chosenWorkflowId = selected?.id - - // Detail data is collected at destination level (not inside a lazy item) so - // scrolling the list cannot cancel it. Cached rows seed the first frame and - // `null` keeps "not loaded yet" distinct from "no stages". - val stages: List? = chosenWorkflowId?.let { id -> - key(id) { - remember(id) { viewModel.observeWorkflowStages(id) } - .collectAsStateWithLifecycle(initialValue = viewModel.cachedWorkflowStages(id)) - .value - } - } - val memberships: List? = chosenWorkflowId?.let { id -> - key(id) { - remember(id) { viewModel.observeWorkflowMemberships(id) } - .collectAsStateWithLifecycle(initialValue = viewModel.cachedWorkflowMemberships(id)) - .value - } - } - val listState = rememberLazyListState() - val detailListState = rememberSaveable(chosenWorkflowId, saver = LazyListState.Saver) { LazyListState() } - val kanbanRowState = rememberLazyListState() // The workflow detail lives inside this destination; back closes it first // and only then falls through to the NavHost's predictive back pop. BackHandler(enabled = selected != null) { selectedId = null } WorkspaceScaffold( - topBar = { - TopAppBar( - title = { - Text( - text = selected?.name ?: "流程看板", - fontWeight = FontWeight.Medium, - ) - }, - navigationIcon = { - IconButton(onClick = { - if (selected != null) selectedId = null else onBack?.invoke() - }) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - if (selected != null) "返回流程列表" else "返回目录", - ) - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), - ) - }, - bottomBar = { - if (selected == null) { - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center, - ) { - FloatingActionIsland( - onQuickCreate = { title, result -> - viewModel.createWorkflowV2(title, result) - }, - placeholder = "输入新流程名称…", - primaryLabel = "新建流程", - fieldLabel = "流程名称", - supportingText = "把相关任务放进同一个流程", + topBar = { + TopAppBar( + title = { + Text( + text = selected?.name ?: "流程看板", + fontWeight = FontWeight.Medium, + ) + }, + navigationIcon = { + IconButton(onClick = { + if (selected != null) selectedId = null else onBack?.invoke() + }) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + if (selected != null) "返回流程列表" else "返回目录", ) } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ) + ) + }, + bottomBar = { + if (selected == null) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + FloatingActionIsland( + onQuickCreate = { title, result -> + viewModel.createWorkflowV2(title, result) + }, + placeholder = "输入新流程名称…", + primaryLabel = "新建流程", + fieldLabel = "流程名称", + supportingText = "把相关任务放进同一个流程", + ) } - }, + } + } ) { padding -> - PullToRefreshBox( - isRefreshing = refreshing, - onRefresh = viewModel::pullRefresh, - modifier = Modifier.fillMaxSize().padding(padding), - ) { - if (selected != null && adaptive.useTwoPane) { - val workflow = selected - val stageList = stages.orEmpty() - val membershipList = memberships.orEmpty() - - Column(Modifier.fillMaxSize()) { - Surface( + PullToRefreshBox( + isRefreshing = refreshing, + onRefresh = viewModel::pullRefresh, + modifier = Modifier.fillMaxSize().padding(padding), + ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 24.dp), + ) { + if (selected == null) { + // Workflow List Overview + items(workflows, key = { it.id }) { workflow -> + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp) + .animateItem() + .clip(TaskDockShapes.Large) + .clickable { selectedId = workflow.id }, + shape = TaskDockShapes.Large, + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = adaptive.pageHorizontalPadding, vertical = 6.dp), - shape = TaskDockShapes.LargeIncreased, - color = MaterialTheme.colorScheme.surfaceContainer, + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + Surface( + shape = TaskDockShapes.Medium, + color = MaterialTheme.colorScheme.tertiaryContainer, + modifier = Modifier.size(42.dp), ) { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text( - text = workflow.name, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Medium, - ) - Text( - text = "${stageList.size} 个阶段 · ${membershipList.size} 项任务流转中", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - if (workflow.archivedAt == null) { - FilledTonalButton( - onClick = { stageWorkflow = workflow }, - shape = TaskDockShapes.FullPill, - ) { - Icon(Icons.Default.Add, null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(6.dp)) - Text("新增阶段") - } - } - ActionMenu( - "流程操作", - listOf( - RowAction("修改名称") { - editWorkflow = workflow - editName = workflow.name - }, - RowAction("新增阶段", enabled = workflow.archivedAt == null) { - stageWorkflow = workflow - }, - RowAction("删除流程", destructive = true) { - deleteWorkflow = workflow - }, - ), + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Default.AccountTree, + contentDescription = null, + tint = MaterialTheme.colorScheme.onTertiaryContainer, + modifier = Modifier.size(22.dp), ) } } - } - if (stages != null && stageList.isEmpty()) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - EmptyState( - icon = Icons.Default.AccountTree, - title = "当前流程暂无阶段", - description = "点击下方添加第一个阶段(如:待开始、进行中)。", - action = { - FilledTonalButton( - onClick = { stageWorkflow = workflow }, - shape = TaskDockShapes.FullPill, - ) { - Icon(Icons.Default.Add, null) - Spacer(Modifier.width(8.dp)) - Text("添加阶段") - } - }, - ) - } - } else { - LazyRow( - state = kanbanRowState, - modifier = Modifier - .fillMaxSize() - .testTag("workflow-kanban-row"), - contentPadding = PaddingValues( - start = adaptive.pageHorizontalPadding, - end = adaptive.pageHorizontalPadding, - top = 6.dp, - bottom = 24.dp, - ), - horizontalArrangement = Arrangement.spacedBy(16.dp), + Spacer(Modifier.width(14.dp)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center, ) { - itemsIndexed(stageList, key = { _, stage -> stage.id }) { index, stage -> - Box( - modifier = Modifier - .width(340.dp) - .fillMaxHeight() - .verticalScroll(rememberScrollState()), - ) { - ExpressiveStageBlock( - workflow = workflow, - stage = stage, - stageIndex = index, - stages = stageList, - memberships = membershipList.filter { it.stageId == stage.id }, - allTasks = tasks, - folders = folders, - onMoveStage = { viewModel.moveWorkflowStageV2(stage, stageList, it) }, - onAddTask = { addTarget = AddTarget(workflow, stage) }, - onCreateTask = { createTaskStage = AddTarget(workflow, stage) }, - onEditStage = { - editStage = it - editName = it.name - }, - onDeleteStage = { deleteStage = it }, - enabled = workflow.archivedAt == null, - onMoveTask = { member, target, before, after -> - viewModel.moveWorkflowMembershipV2(member, target, before, after) - }, - onRemoveTask = { viewModel.removeWorkflowMembershipV2(it) }, - onToggleStatus = { task, status -> - viewModel.updateTaskStatus(task, status) - }, - onNavigateToDetail = onNavigateToDetail, - isKanbanColumn = true, - ) - } - } + Text( + text = workflow.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = if (workflow.archivedAt == null) "查看阶段与任务状态" else "已归档", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } + + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) } } - } else { - LazyColumn( - state = if (selected == null) listState else detailListState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(bottom = 24.dp), - ) { - if (selected == null) { - // Workflow List Overview - val useGridWorkflows = adaptive.useTwoPane && workflows.size > 1 - if (useGridWorkflows) { - items(workflows.chunked(2), key = { pair -> pair.first().id }) { rowItems -> - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = adaptive.pageHorizontalPadding, vertical = 6.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - rowItems.forEach { workflow -> - WorkflowOverviewCard( - workflow = workflow, - onClick = { selectedId = workflow.id }, - modifier = Modifier.weight(1f), - ) - } - if (rowItems.size == 1) { - Spacer(Modifier.weight(1f)) - } - } - } - } else { - items(workflows, key = { it.id }) { workflow -> - WorkflowOverviewCard( - workflow = workflow, - onClick = { selectedId = workflow.id }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 6.dp) - .animateItem(), - ) - } - } + } - if (workflows.isEmpty()) { - item { - EmptyState( - icon = Icons.Default.AccountTree, - title = "将任务按阶段组织为流程", - description = "例如“需求设计 → 开发中 → 验收测试”,任务状态在各处实时保持同步。", - action = { - FilledTonalButton( - onClick = { showCreate = true }, - shape = TaskDockShapes.FullPill, - ) { - Icon(Icons.Default.Add, null) - Spacer(Modifier.width(8.dp)) - Text("新建流程") - } - }, - ) + if (workflows.isEmpty()) { + item { + EmptyState( + icon = Icons.Default.AccountTree, + title = "将任务按阶段组织为流程", + description = "例如“需求设计 → 开发中 → 验收测试”,任务状态在各处实时保持同步。", + action = { + FilledTonalButton( + onClick = { showCreate = true }, + shape = TaskDockShapes.FullPill, + ) { + Icon(Icons.Default.Add, null) + Spacer(Modifier.width(8.dp)) + Text("新建流程") } } - } else { - // Workflow Detail & Stages View - item(key = selected.id) { - val workflow = selected - val stageList = stages.orEmpty() - val membershipList = memberships.orEmpty() + ) + } + } + } else { + // Workflow Detail & Stages View + item(key = selected.id) { + val workflow = selected + val stages by remember(workflow.id) { + viewModel.observeWorkflowStages(workflow.id) + }.collectAsStateWithLifecycle(initialValue = emptyList()) + val memberships by remember(workflow.id) { + viewModel.observeWorkflowMemberships(workflow.id) + }.collectAsStateWithLifecycle(initialValue = emptyList()) + + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + shape = TaskDockShapes.LargeIncreased, + color = MaterialTheme.colorScheme.surfaceContainer, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = workflow.name, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Medium, + ) + Text( + text = "${stages.size} 个阶段 · ${memberships.size} 项任务流转中", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - shape = TaskDockShapes.LargeIncreased, - color = MaterialTheme.colorScheme.surfaceContainer, - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text( - text = workflow.name, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Medium, - ) - Text( - text = "${stageList.size} 个阶段 · ${membershipList.size} 项任务流转中", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - ActionMenu( - "流程操作", - listOf( - RowAction("修改名称") { - editWorkflow = workflow - editName = workflow.name - }, - RowAction("新增阶段", enabled = workflow.archivedAt == null) { - stageWorkflow = workflow - }, - RowAction("删除流程", destructive = true) { - deleteWorkflow = workflow - }, - ), - ) - } - } + ActionMenu( + "流程操作", + listOf( + RowAction("修改名称") { + editWorkflow = workflow + editName = workflow.name + }, + RowAction("新增阶段", enabled = workflow.archivedAt == null) { + stageWorkflow = workflow + }, + RowAction("删除流程", destructive = true) { + deleteWorkflow = workflow + }, + ), + ) + } + } - stageList.forEachIndexed { index, stage -> - ExpressiveStageBlock( - workflow = workflow, - stage = stage, - stageIndex = index, - stages = stageList, - memberships = membershipList.filter { it.stageId == stage.id }, - allTasks = tasks, - folders = folders, - onMoveStage = { viewModel.moveWorkflowStageV2(stage, stageList, it) }, - onAddTask = { addTarget = AddTarget(workflow, stage) }, - onCreateTask = { createTaskStage = AddTarget(workflow, stage) }, - onEditStage = { - editStage = it - editName = it.name - }, - onDeleteStage = { deleteStage = it }, - enabled = workflow.archivedAt == null, - onMoveTask = { member, target, before, after -> - viewModel.moveWorkflowMembershipV2(member, target, before, after) - }, - onRemoveTask = { viewModel.removeWorkflowMembershipV2(it) }, - onToggleStatus = { task, status -> - viewModel.updateTaskStatus(task, status) - }, - onNavigateToDetail = onNavigateToDetail, - ) - } + stages.forEachIndexed { index, stage -> + ExpressiveStageBlock( + workflow = workflow, + stage = stage, + stageIndex = index, + stages = stages, + memberships = memberships.filter { it.stageId == stage.id }, + allTasks = tasks, + folders = folders, + onMoveStage = { viewModel.moveWorkflowStageV2(stage, stages, it) }, + onAddTask = { addTarget = AddTarget(workflow, stage) }, + onCreateTask = { createTaskStage = AddTarget(workflow, stage) }, + onEditStage = { + editStage = it + editName = it.name + }, + onDeleteStage = { deleteStage = it }, + enabled = workflow.archivedAt == null, + onMoveTask = { member, target, before, after -> + viewModel.moveWorkflowMembershipV2(member, target, before, after) + }, + onRemoveTask = { viewModel.removeWorkflowMembershipV2(it) }, + onToggleStatus = { task, status -> + viewModel.updateTaskStatus(task, status) + }, + onNavigateToDetail = onNavigateToDetail, + ) + } - if (stages != null && stageList.isEmpty()) { - EmptyState( - icon = Icons.Default.AccountTree, - title = "当前流程暂无阶段", - description = "点击下方添加第一个阶段(如:待开始、进行中)。", - action = { - FilledTonalButton( - onClick = { stageWorkflow = workflow }, - shape = TaskDockShapes.FullPill, - ) { - Icon(Icons.Default.Add, null) - Spacer(Modifier.width(8.dp)) - Text("添加阶段") - } - }, - ) + if (stages.isEmpty()) { + EmptyState( + icon = Icons.Default.AccountTree, + title = "当前流程暂无阶段", + description = "点击下方添加第一个阶段(如:待开始、进行中)。", + action = { + FilledTonalButton( + onClick = { stageWorkflow = workflow }, + shape = TaskDockShapes.FullPill, + ) { + Icon(Icons.Default.Add, null) + Spacer(Modifier.width(8.dp)) + Text("添加阶段") } } - } + ) } } } } + } + } deleteStage?.let { stage -> AlertDialog( @@ -615,93 +484,16 @@ fun WorkflowsV2Screen( } addTarget?.let { target -> - val candidateTasks = tasks.filter { - it.archivedAt == null && it.deletedAt == null - } - AlertDialog( - onDismissRequest = { addTarget = null }, - title = { Text("添加已有任务至 ${target.stage.name}") }, - text = { - Column( - modifier = Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - candidateTasks.forEach { task -> - TextButton( - onClick = { - viewModel.addWorkflowTaskV2(target.workflow, target.stage, task) - addTarget = null - }, - modifier = Modifier.fillMaxWidth(), - ) { - Text(task.title, maxLines = 1, overflow = TextOverflow.Ellipsis) - } - } + WorkflowDirectoryTaskPickerDialog( + stageName = target.stage.name, + allTasks = tasks, + allFolders = folders, + onDismiss = { addTarget = null }, + onTaskSelect = { task -> + viewModel.addWorkflowTaskV2(target.workflow, target.stage, task) + addTarget = null } - }, - confirmButton = { TextButton(onClick = { addTarget = null }) { Text("完成") } }, - ) - } -} - -@Composable -private fun WorkflowOverviewCard( - workflow: WorkflowEntity, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Surface( - modifier = modifier - .clip(TaskDockShapes.Large) - .clickable(onClick = onClick), - shape = TaskDockShapes.Large, - color = MaterialTheme.colorScheme.surfaceContainerLow, - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Surface( - shape = TaskDockShapes.Medium, - color = MaterialTheme.colorScheme.tertiaryContainer, - modifier = Modifier.size(42.dp), - ) { - Box(contentAlignment = Alignment.Center) { - Icon( - imageVector = Icons.Default.AccountTree, - contentDescription = null, - tint = MaterialTheme.colorScheme.onTertiaryContainer, - modifier = Modifier.size(22.dp), - ) - } - } - - Spacer(Modifier.width(14.dp)) - - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.Center, - ) { - Text( - text = workflow.name, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = if (workflow.archivedAt == null) "查看阶段与任务状态" else "已归档", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - Icon( - imageVector = Icons.Default.ChevronRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), - ) - } + ) } } @@ -724,7 +516,6 @@ private fun ExpressiveStageBlock( onRemoveTask: (WorkflowTaskMembershipEntity) -> Unit, onToggleStatus: (TaskEntity, TaskStatus) -> Unit, onNavigateToDetail: (String) -> Unit, - isKanbanColumn: Boolean = false, ) { val sortedMemberships = remember(memberships) { memberships.sortedWith(compareBy { it.rank.toLongOrNull() ?: 0L }.thenBy { it.id }) @@ -740,7 +531,7 @@ private fun ExpressiveStageBlock( Surface( modifier = Modifier .fillMaxWidth() - .padding(horizontal = if (isKanbanColumn) 0.dp else 16.dp, vertical = if (isKanbanColumn) 0.dp else 6.dp) + .padding(horizontal = 16.dp, vertical = 6.dp) .animateContentSize(animationSpec = TaskDockMotion.springSpatial()), shape = TaskDockShapes.LargeIncreased, color = MaterialTheme.colorScheme.surfaceContainerLow, @@ -834,3 +625,182 @@ private fun ExpressiveStageBlock( } private data class AddTarget(val workflow: WorkflowEntity, val stage: WorkflowStageEntity) + +@Composable +private fun WorkflowDirectoryTaskPickerDialog( + stageName: String, + allTasks: List, + allFolders: List, + onDismiss: () -> Unit, + onTaskSelect: (TaskEntity) -> Unit, +) { + var query by rememberSaveable { mutableStateOf("") } + var currentFolderId by rememberSaveable { mutableStateOf(null) } + + val foldersById = remember(allFolders) { allFolders.associateBy { it.id } } + + val breadcrumbs = remember(currentFolderId, foldersById) { + val list = mutableListOf() + var curr = currentFolderId?.let { foldersById[it] } + var visited = 0 + while (curr != null && visited < allFolders.size) { + list.add(0, curr) + curr = curr.parentFolderId?.let { foldersById[it] } + visited++ + } + list + } + + val visibleFolders = remember(allFolders, currentFolderId) { + allFolders.filter { + it.archivedAt == null && it.deletedAt == null && it.parentFolderId == currentFolderId + }.sortedBy { it.title } + } + + val visibleTasks = remember(allTasks, currentFolderId, query) { + val needle = query.trim().lowercase() + if (needle.isNotEmpty()) { + allTasks.filter { + it.archivedAt == null && it.deletedAt == null && + (it.title.lowercase().contains(needle) || (it.referenceId?.lowercase()?.contains(needle) == true)) + } + } else { + allTasks.filter { + it.archivedAt == null && it.deletedAt == null && it.parentFolderId == currentFolderId + }.sortedBy { it.title } + } + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("添加任务到「$stageName」") }, + text = { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 480.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = query, + onValueChange = { query = it }, + label = { Text("搜索任务或引用 ID") }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + if (query.trim().isEmpty()) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + FilterChip( + selected = currentFolderId == null, + onClick = { currentFolderId = null }, + label = { Text("根目录") }, + ) + for (folder in breadcrumbs) { + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + FilterChip( + selected = currentFolderId == folder.id, + onClick = { currentFolderId = folder.id }, + label = { Text(folder.title) }, + ) + } + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (query.trim().isEmpty()) { + for (folder in visibleFolders) { + Surface( + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + modifier = Modifier + .fillMaxWidth() + .clickable { currentFolderId = folder.id } + .padding(vertical = 4.dp), + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + Icons.Default.Folder, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Text( + text = folder.title, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp), + ) + } + } + } + } + + for (task in visibleTasks) { + TextButton( + onClick = { onTaskSelect(task) }, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = task.title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = task.referenceId.orEmpty(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + if (visibleFolders.isEmpty() && visibleTasks.isEmpty()) { + Text( + text = if (query.trim().isNotEmpty()) "没有匹配的任务" else "此目录下没有未完成任务", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 16.dp), + ) + } + } + } + }, + confirmButton = { TextButton(onClick = onDismiss) { Text("取消") } }, + ) +} diff --git a/apps/web/src/WorkflowsPage.tsx b/apps/web/src/WorkflowsPage.tsx index c17f835..5460957 100644 --- a/apps/web/src/WorkflowsPage.tsx +++ b/apps/web/src/WorkflowsPage.tsx @@ -5,24 +5,15 @@ import type { WorkflowDto, WorkflowTaskMembershipDto, } from '@devtodo/contracts'; -import { useCallback, useEffect, useId, useRef, useState } from 'react'; -import { - Check, - Folder, - ListChecks as ListChecksIcon, - MoreHorizontal, - Plus, - Search, - Workflow as WorkflowGlyph, -} from 'lucide-react'; +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; +import { Folder, MoreHorizontal, Plus, Workflow as WorkflowGlyph } from 'lucide-react'; import { mutationV2, requestV2 } from './api.js'; +import { shouldReloadTaskList } from './data-changes.js'; import { useAuth } from './auth.js'; -import { notifyDataChanged, shouldReloadTaskList } from './data-changes.js'; import { Alert, Button, - ButtonGroup, Card, ConfirmDialog, Dialog, @@ -32,7 +23,6 @@ import { ListItem, LoadingState, Menu, - SearchBar, Select, TaskStatusControl, TextField, @@ -41,7 +31,8 @@ import { } from './components/m3e/index.js'; import { resolveCaptureFolder } from './folder-preference.js'; import { useSearchParams } from 'react-router-dom'; -import { TaskFolderBreadcrumb } from './components/task-folder-breadcrumb.js'; +import { directoryEntries } from './directory-paths.js'; +import { TaskDirectoryPickerModal } from './components/task-directory-picker-modal.js'; type WorkflowStage = NonNullable[number]; @@ -184,181 +175,43 @@ function WorkflowNameDialog({ */ function WorkflowAddTaskDialog({ stage, - candidates, - folders, - busy, - error, onAddExisting, onCreateTask, onClose, }: { stage: WorkflowStage; - candidates: TreeTaskDto[]; - folders: FolderDto[]; - busy: boolean; - error: string; + candidates?: TreeTaskDto[]; + folders?: FolderDto[]; + busy?: boolean; + error?: string; onAddExisting: (taskIds: string[]) => void; onCreateTask: (title: string) => void; onClose: () => void; }) { - const [mode, setMode] = useState<'existing' | 'create'>('existing'); - const [query, setQuery] = useState(''); - const [selected, setSelected] = useState>(() => new Set()); - const [title, setTitle] = useState(''); - const formId = useId(); - const needle = query.trim().toLowerCase(); - const matches = needle - ? candidates.filter( - (task) => - task.title.toLowerCase().includes(needle) || - task.referenceId.toLowerCase().includes(needle), - ) - : candidates; - const toggle = (taskId: string) => - setSelected((current) => { - const next = new Set(current); - if (next.has(taskId)) next.delete(taskId); - else next.add(taskId); - return next; - }); - const folderTitle = (task: TreeTaskDto) => - task.parentFolderId - ? (folders.find((folder) => folder.id === task.parentFolderId)?.title ?? '目录') - : '根目录'; - return ( - `加入流程${count ? `(${count})` : ''}`} + excludedTaskIds={useMemo(() => { + // 通过动态判断:若不在候选列表中,则代表已在流程中,应被排除 + // 但 TaskDirectoryPickerModal 接收的是 excludedTaskIds + // 所以我们可以在组件中使用 excludedTaskIds,或者直接传已在流程中的 ID 集合 + return new Set((stage.tasks ?? []).map((t) => t.id)); + }, [stage.tasks])} onClose={onClose} - footer={ - mode === 'existing' ? ( - <> - - 已选择 {selected.size} 项 - - - - - ) : ( - <> - - - - ) - } - > - - {error && {error}} - {mode === 'existing' ? ( - <> - } - /> - {matches.length ? ( - - {matches.map((task) => { - const isSelected = selected.has(task.id); - return ( - - {task.referenceId} - - - - } - leading={ - - } - selected={isSelected} - disabled={busy} - ariaLabel={`${isSelected ? '取消选择' : '选择'}任务 ${task.title}`} - onClick={() => toggle(task.id)} - /> - ); - })} - - ) : ( - } - title={candidates.length ? '没有匹配的任务' : '没有可加入的任务'} - description={ - candidates.length - ? '换一个标题或引用 ID 再试。' - : '目录里的任务都已在这个流程中;可以改为新建任务。' - } - /> - )} - - ) : ( -
{ - event.preventDefault(); - const next = title.trim(); - if (next && !busy) onCreateTask(next); - }} - > - setTitle(event.target.value)} - /> - - )} -
+ createTaskSupport={{ + onCreateTask: async (title) => { + onCreateTask(title); + }, + helperText: '新任务会先创建在默认捕获目录,再加入这个阶段。', + }} + onConfirm={async (tasks) => { + onAddExisting(tasks.map((t) => t.id)); + }} + /> ); } -/** - * Workflow board. A workflow is a card, its stages are columns in reading - * order, and every mutation is reachable from one overflow menu per card, - * column and task row. - */ export function WorkflowsPage() { const { settings } = useAuth(); const [workflows, setWorkflows] = useState([]); @@ -488,7 +341,7 @@ export function WorkflowsPage() { const applied = await run(() => mutationV2('PATCH', `/tasks/${task.id}`, { status, baseVersion: task.version }), ); - if (applied) notifyDataChanged(load); + if (applied) window.dispatchEvent(new Event('devtodo:data-changed')); }; const moveMembership = ( @@ -506,6 +359,9 @@ export function WorkflowsPage() { ? selectedWorkflowId : (workflows[0]?.id ?? ''); const visibleWorkflows = workflows.filter((item) => item.id === activeWorkflowId); + const folderLabels = new Map( + directoryEntries(folders).map((entry) => [entry.folder.id, entry.label]), + ); return (
@@ -717,6 +573,9 @@ export function WorkflowsPage() { const membership = membershipAt(stage, taskIndex); const prevMembership = membershipAt(stage, taskIndex - 1); const nextMembership = membershipAt(stage, taskIndex + 1); + const folderTitle = task.parentFolderId + ? (folderLabels.get(task.parentFolderId) ?? '目录') + : '根目录'; return ( - + + } trailing={ diff --git a/apps/web/src/components/task-directory-picker-modal.tsx b/apps/web/src/components/task-directory-picker-modal.tsx new file mode 100644 index 0000000..528ddd8 --- /dev/null +++ b/apps/web/src/components/task-directory-picker-modal.tsx @@ -0,0 +1,537 @@ +import type { FolderDto, TaskStatus, TreeItemDto, TreeTaskDto } from '@devtodo/contracts'; +import { + Check, + ChevronRight, + Folder, + ListChecks as ListChecksIcon, + Plus, + Search, +} from 'lucide-react'; +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; +import { ApiError, requestV2 } from '../api.js'; +import { useDevice } from '../device.js'; +import { shouldReloadTaskList } from '../data-changes.js'; +import { TaskHoverPreviewRow } from './task-hover-preview.js'; +import { + Alert, + Button, + ButtonGroup, + Card, + Chip, + Dialog, + EmptyState as M3EmptyState, + List, + ListItem, + LoadingState, + SearchBar, + TextField, +} from './m3e/index.js'; + +function taskStatusLabel(status: TaskStatus): string { + return status === 'IN_PROGRESS' ? '进行中' : status === 'DONE' ? '已完成' : '待开始'; +} + +function taskStatusClass(status: TaskStatus): string { + return status === 'IN_PROGRESS' ? 'in-progress' : status === 'DONE' ? 'done' : 'todo'; +} + +export interface TaskDirectoryPickerModalProps { + title: string; + confirmLabel?: (count: number) => string; + excludedTaskIds?: ReadonlySet; + onConfirm: (tasks: TreeTaskDto[]) => Promise | void; + onClose: () => void; + createTaskSupport?: { + onCreateTask: (title: string) => Promise | void; + helperText?: string; + }; +} + +export function TaskDirectoryPickerModal({ + title, + confirmLabel, + excludedTaskIds, + onConfirm, + onClose, + createTaskSupport, +}: TaskDirectoryPickerModalProps) { + const { isTouch } = useDevice(); + const [activeTab, setActiveTab] = useState<'existing' | 'create'>('existing'); + const [newTitle, setNewTitle] = useState(''); + const createFormId = useId(); + + const [query, setQuery] = useState(''); + const [folderId, setFolderId] = useState(null); + const [items, setItems] = useState([]); + const [path, setPath] = useState>>([]); + const [searchIndex, setSearchIndex] = useState<{ + tasks: TreeTaskDto[]; + folders: FolderDto[]; + } | null>(null); + const [error, setError] = useState(''); + const [pickerError, setPickerError] = useState(''); + const [searchError, setSearchError] = useState(''); + const [searchRetry, setSearchRetry] = useState(0); + const [pickerLoading, setPickerLoading] = useState(false); + const [selectedTasks, setSelectedTasks] = useState>(() => new Map()); + const [submitting, setSubmitting] = useState(false); + const pickerLoadSequence = useRef(0); + const searchLoadSequence = useRef(0); + const loadedPickerFolder = useRef(undefined); + + const loadPicker = useCallback(async () => { + const sequence = ++pickerLoadSequence.current; + setPickerLoading(true); + setPickerError(''); + try { + const queryString = folderId + ? `?parentFolderId=${encodeURIComponent(folderId)}` + : '?parentFolderId=root'; + const [children, nextPath] = await Promise.all([ + requestV2<{ items: TreeItemDto[] }>(`/tree/children${queryString}`), + folderId + ? requestV2<{ items: Array> }>( + `/folders/${encodeURIComponent(folderId)}/path`, + ) + : Promise.resolve({ items: [] }), + ]); + if (sequence !== pickerLoadSequence.current) return; + loadedPickerFolder.current = folderId; + setItems(children.items); + setPath(nextPath.items); + } catch (cause) { + if (sequence === pickerLoadSequence.current) + setPickerError(cause instanceof ApiError ? cause.message : '任务目录加载失败,请重试'); + } finally { + if (sequence === pickerLoadSequence.current) setPickerLoading(false); + } + }, [folderId]); + + useEffect(() => { + void loadPicker(); + const listener = (event: Event) => { + if (!shouldReloadTaskList(event)) return; + setSearchError(''); + setSearchRetry((count) => count + 1); + void loadPicker(); + }; + window.addEventListener('devtodo:data-changed', listener); + return () => window.removeEventListener('devtodo:data-changed', listener); + }, [loadPicker]); + + const hasSearchQuery = Boolean(query.trim()); + useEffect(() => { + if (!hasSearchQuery) return; + let cancelled = false; + const sequence = ++searchLoadSequence.current; + const timeout = window.setTimeout(() => { + setSearchError(''); + void Promise.all([ + requestV2<{ items: TreeTaskDto[] }>('/tasks'), + requestV2<{ items: FolderDto[] }>('/folders'), + ]) + .then(([taskResult, folderResult]) => { + if (cancelled || sequence !== searchLoadSequence.current) return; + setSearchIndex({ tasks: taskResult.items, folders: folderResult.items }); + }) + .catch((cause) => { + if (cancelled || sequence !== searchLoadSequence.current) return; + setSearchError(cause instanceof ApiError ? cause.message : '搜索索引加载失败,请重试'); + }); + }, 180); + return () => { + cancelled = true; + window.clearTimeout(timeout); + if (sequence === searchLoadSequence.current) searchLoadSequence.current += 1; + }; + }, [hasSearchQuery, searchRetry]); + + const activeItems = useMemo( + () => + items.filter((item) => { + if (item.kind === 'FOLDER') return true; + if (item.task.deletedAt || item.task.status === 'DONE') return false; + return true; + }), + [items], + ); + + const searchFoldersById = useMemo(() => { + const byId = new Map(); + for (const folder of searchIndex?.folders ?? []) byId.set(folder.id, folder); + return byId; + }, [searchIndex]); + + const taskLocation = useCallback( + (task: TreeTaskDto) => { + const titles: string[] = []; + let parentId = task.parentFolderId; + let visited = 0; + while (parentId && visited < (searchIndex?.folders.length ?? 0)) { + const folder = searchFoldersById.get(parentId); + if (!folder) break; + titles.unshift(folder.title); + parentId = folder.parentFolderId; + visited += 1; + } + return titles.join(' / ') || '根目录'; + }, + [searchFoldersById, searchIndex?.folders.length], + ); + + const matchingTasks = useMemo(() => { + const needle = query.trim().toLocaleLowerCase(); + if (!needle || !searchIndex) return []; + return searchIndex.tasks.filter((task) => { + if (task.deletedAt || task.status === 'DONE') return false; + return ( + task.title.toLocaleLowerCase().includes(needle) || + task.referenceId.toLocaleLowerCase().includes(needle) || + taskLocation(task).toLocaleLowerCase().includes(needle) + ); + }); + }, [query, searchIndex, taskLocation]); + + const searchLoading = Boolean(query.trim()) && !searchIndex && !searchError; + + const openFolder = (id: string | null) => { + setQuery(''); + setFolderId(id); + }; + + const toggleTaskSelection = (task: TreeTaskDto) => { + setSelectedTasks((current) => { + const next = new Map(current); + if (next.has(task.id)) next.delete(task.id); + else next.set(task.id, task); + return next; + }); + }; + + const handleConfirmSelected = async () => { + if (submitting || selectedTasks.size === 0) return; + setSubmitting(true); + setError(''); + try { + await onConfirm([...selectedTasks.values()]); + } catch (cause) { + setError(cause instanceof ApiError ? cause.message : '操作失败,请重试'); + } finally { + setSubmitting(false); + } + }; + + const handleCreateSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = newTitle.trim(); + if (!trimmed || !createTaskSupport || submitting) return; + setSubmitting(true); + setError(''); + try { + await createTaskSupport.onCreateTask(trimmed); + } catch (cause) { + setError(cause instanceof ApiError ? cause.message : '创建任务失败,请重试'); + } finally { + setSubmitting(false); + } + }; + + const renderTaskRow = (task: TreeTaskDto, showLocation: boolean) => { + const selected = selectedTasks.has(task.id); + const alreadyAdded = excludedTaskIds?.has(task.id) ?? false; + return ( + + {(anchorProps) => ( + {taskLocation(task)} + ) : undefined + } + leading={ + + } + trailing={ + alreadyAdded ? ( + + ) : ( + !selected && + ); + }; + + const renderTreeItem = (item: TreeItemDto) => + item.kind === 'FOLDER' ? ( + } + headline={item.folder.title} + supporting={ + <> + + + {item.aggregate.doneCount}/{item.aggregate.totalCount} 已完成 + + + } + trailing={