git.iko.soy/opencode-android
on 06fd41b help sign in
205 lines 9.4 kB history raw
1package soy.iko.opencode.ui.chat
3import androidx.compose.animation.AnimatedVisibility
4import androidx.compose.foundation.clickable
5import androidx.compose.foundation.layout.Arrangement
6import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.Row
8import androidx.compose.foundation.layout.Spacer
9import androidx.compose.foundation.layout.defaultMinSize
10import androidx.compose.foundation.layout.fillMaxWidth
11import androidx.compose.foundation.layout.heightIn
12import androidx.compose.foundation.layout.padding
13import androidx.compose.foundation.layout.size
14import androidx.compose.foundation.rememberScrollState
15import androidx.compose.foundation.verticalScroll
16import androidx.compose.material.icons.Icons
17import androidx.compose.material.icons.filled.Autorenew
18import androidx.compose.material.icons.filled.Cancel
19import androidx.compose.material.icons.filled.CheckCircle
20import androidx.compose.material.icons.filled.Checklist
21import androidx.compose.material.icons.filled.ExpandLess
22import androidx.compose.material.icons.filled.ExpandMore
23import androidx.compose.material.icons.filled.RadioButtonUnchecked
24import androidx.compose.material3.Icon
25import androidx.compose.material3.MaterialTheme
26import androidx.compose.material3.Surface
27import androidx.compose.material3.Text
28import androidx.compose.runtime.Composable
29import androidx.compose.runtime.getValue
30import androidx.compose.runtime.mutableStateOf
31import androidx.compose.runtime.remember
32import androidx.compose.runtime.saveable.rememberSaveable
33import androidx.compose.runtime.setValue
34import androidx.compose.ui.Alignment
35import androidx.compose.ui.Modifier
36import androidx.compose.ui.res.stringResource
37import androidx.compose.ui.semantics.Role
38import androidx.compose.ui.semantics.contentDescription
39import androidx.compose.ui.semantics.semantics
40import androidx.compose.ui.semantics.stateDescription
41import androidx.compose.ui.text.font.FontWeight
42import androidx.compose.ui.text.style.TextDecoration
43import androidx.compose.ui.unit.dp
44import soy.iko.opencode.R
45import soy.iko.opencode.data.model.TodoItem
46import soy.iko.opencode.data.model.TodoPriority
47import soy.iko.opencode.data.model.TodoStatus
48import soy.iko.opencode.data.model.priorityEnum
49import soy.iko.opencode.data.model.statusEnum
50import soy.iko.opencode.ui.components.rememberVisibilityTransitions
52/** Renders a task plan as a checklist: a status icon per row plus its description, with
53 * completed/cancelled items struck through and the in-progress item emphasized. */
54@Composable
55fun TodoPlanChecklist(todos: List<TodoItem>, modifier: Modifier = Modifier) {
56 Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
57 for (todo in todos) TodoRow(todo)
58 }
61@Composable
62private fun TodoRow(todo: TodoItem) {
63 val status = todo.statusEnum()
64 val priority = todo.priorityEnum()
65 val struck = status == TodoStatus.COMPLETED || status == TodoStatus.CANCELLED
66 Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
67 TodoStatusIcon(status)
68 Text(
69 todo.content,
70 style = MaterialTheme.typography.bodySmall,
71 color = when {
72 status == TodoStatus.IN_PROGRESS -> MaterialTheme.colorScheme.primary
73 struck -> MaterialTheme.colorScheme.onSurfaceVariant
74 else -> MaterialTheme.colorScheme.onSurface
75 },
76 fontWeight = if (status == TodoStatus.IN_PROGRESS) FontWeight.Medium else null,
77 textDecoration = if (struck) TextDecoration.LineThrough else null,
78 modifier = Modifier.padding(start = 8.dp).weight(1f),
79 )
80 // Priority badge: a small uppercase label to the right of the content. Tinted to
81 // distinguish high (error) from medium (tertiary) from low (muted); skipped entirely
82 // when the server didn't report a priority (NONE) so a plain todo doesn't get a
83 // meaningless "LOW" chip. Struck-through items keep the badge but muted, so a
84 // completed/cancelled high-priority task still records its original urgency.
85 if (priority != TodoPriority.NONE) {
86 val label = when (priority) {
87 TodoPriority.HIGH -> stringResource(R.string.todo_priority_high)
88 TodoPriority.MEDIUM -> stringResource(R.string.todo_priority_medium)
89 TodoPriority.LOW -> stringResource(R.string.todo_priority_low)
90 TodoPriority.NONE -> ""
91 }
92 val tint = when (priority) {
93 TodoPriority.HIGH -> if (struck) MaterialTheme.colorScheme.onSurfaceVariant
94 else MaterialTheme.colorScheme.error
95 TodoPriority.MEDIUM -> if (struck) MaterialTheme.colorScheme.onSurfaceVariant
96 else MaterialTheme.colorScheme.tertiary
97 TodoPriority.LOW -> MaterialTheme.colorScheme.onSurfaceVariant
98 TodoPriority.NONE -> MaterialTheme.colorScheme.onSurfaceVariant
99 }
100 val a11yLabel = stringResource(R.string.todo_priority_label, label)
101 Text(
102 label.uppercase(),
103 style = MaterialTheme.typography.labelSmall,
104 color = tint,
105 modifier = Modifier
106 .padding(start = 8.dp, top = 2.dp)
107 .semantics { contentDescription = a11yLabel },
108 )
109 }
110 }
113@Composable
114private fun TodoStatusIcon(status: TodoStatus) {
115 val icon = when (status) {
116 TodoStatus.COMPLETED -> Icons.Filled.CheckCircle
117 TodoStatus.IN_PROGRESS -> Icons.Filled.Autorenew
118 TodoStatus.CANCELLED -> Icons.Filled.Cancel
119 else -> Icons.Filled.RadioButtonUnchecked
120 }
121 val tint = when (status) {
122 TodoStatus.COMPLETED, TodoStatus.IN_PROGRESS -> MaterialTheme.colorScheme.primary
123 else -> MaterialTheme.colorScheme.onSurfaceVariant
124 }
125 val desc = when (status) {
126 TodoStatus.COMPLETED -> R.string.todo_completed
127 TodoStatus.IN_PROGRESS -> R.string.todo_in_progress
128 TodoStatus.CANCELLED -> R.string.todo_cancelled
129 else -> R.string.todo_pending
130 }
131 Icon(
132 icon,
133 contentDescription = stringResource(desc),
134 tint = tint,
135 modifier = Modifier.size(16.dp),
136 )
139/** A collapsible plan bar pinned above the composer: the header shows completed/total and
140 * a chevron; expanding reveals the full (scrollable, height-capped) checklist. Renders
141 * nothing when there's no plan, so the caller can place it unconditionally. */
142@Composable
143fun TodoPlanBar(todos: List<TodoItem>, modifier: Modifier = Modifier) {
144 if (todos.isEmpty()) return
145 var expanded by rememberSaveable { mutableStateOf(false) }
146 val completed = remember(todos) { todos.count { it.statusEnum() == TodoStatus.COMPLETED } }
147 val expandedState = stringResource(R.string.state_expanded)
148 val collapsedState = stringResource(R.string.state_collapsed)
149 Surface(
150 color = MaterialTheme.colorScheme.surfaceVariant,
151 tonalElevation = 2.dp,
152 modifier = modifier.fillMaxWidth(),
153 ) {
154 Column {
155 Row(
156 modifier = Modifier
157 .fillMaxWidth()
158 .defaultMinSize(minHeight = 48.dp)
159 .clickable(role = Role.Button) { expanded = !expanded }
160 .semantics { stateDescription = if (expanded) expandedState else collapsedState }
161 .padding(horizontal = 12.dp, vertical = 6.dp),
162 verticalAlignment = Alignment.CenterVertically,
163 ) {
164 Icon(
165 Icons.Filled.Checklist,
166 contentDescription = null,
167 tint = MaterialTheme.colorScheme.onSurfaceVariant,
168 modifier = Modifier.size(18.dp),
169 )
170 Text(
171 stringResource(R.string.plan),
172 style = MaterialTheme.typography.labelLarge,
173 modifier = Modifier.padding(start = 8.dp),
174 )
175 Text(
176 stringResource(R.string.plan_progress, completed, todos.size),
177 style = MaterialTheme.typography.labelMedium,
178 color = MaterialTheme.colorScheme.onSurfaceVariant,
179 modifier = Modifier.padding(start = 8.dp),
180 )
181 Spacer(Modifier.weight(1f))
182 Icon(
183 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
184 contentDescription = null,
185 tint = MaterialTheme.colorScheme.onSurfaceVariant,
186 modifier = Modifier.size(20.dp),
187 )
188 }
189 val expandMotion = rememberVisibilityTransitions()
190 AnimatedVisibility(
191 visible = expanded,
192 enter = expandMotion.enter,
193 exit = expandMotion.exit,
194 ) {
195 TodoPlanChecklist(
196 todos,
197 modifier = Modifier
198 .heightIn(max = 240.dp)
199 .verticalScroll(rememberScrollState())
200 .padding(start = 12.dp, end = 12.dp, bottom = 10.dp),
201 )
202 }
203 }
204 }