updated android

cf43072 · gmorell · 2026-06-18 22:42

16 files +231 -8

Files changed

modified android/README.md
+5 −3
@@ -9,8 +9,10 @@ Every phase (0–6 plus 5b) of the rollout in [docs/android.md](../docs/android.
9 9 is shipped: auth (QR pair + magic link), read-only boards, real-time
10 10 updates, card writes, labels/fields/assignees/notes, drag-and-drop
11 11 (with within-column reorder + auto-scroll), board settings + invites +
12 −app invites, subboards (2-D grid view with reordering), the workflow
13 −template editor, the 9-theme picker, and the IconKitchen launcher icon.
12 +app invites, subboards (2-D grid view with reordering), board-label
13 +management (add/recolor/delete, optionally scoped to specific rows), the
14 +workflow template editor, the 9-theme picker, and the IconKitchen launcher
15 +icon.
14 16
15 17 ## Stack
16 18
@@ -53,7 +55,7 @@ android/
53 55 ui/
54 56 BoardsScreen.kt # boards list + theme picker dropdown
55 57 BoardScreen.kt # kanban view (1-D or 2-D grid) + DnD + FAB
56 − BoardSettingsScreen.kt # 4-tab: Settings / Members / Invites / Rows
58 + BoardSettingsScreen.kt # 5-tab: Settings / Members / Invites / Rows / Labels
57 59 AppInvitesScreen.kt # registration invites
58 60 CardSheet.kt # bottom sheet: title/move/labels/fields/notes
59 61 HistoryScreen.kt # activity log
modified android/app/src/main/java/org/neiam/waxx/app/data/Models.kt
+24 −1
@@ -112,7 +112,18 @@ data class Label(
112 112 val id: String,
113 113 val name: String,
114 114 val color: String? = null,
115 −)
115 + // Subboards this label is scoped to. Empty = board-wide (applies to every
116 + // card). Present on board workflows; template labels omit it (defaults []).
117 + val subboard_ids: List<String> = emptyList(),
118 +) {
119 + /**
120 + * Whether this label may be applied to a card sitting in [subboardId]
121 + * (null = the default row). Board-wide labels apply everywhere; scoped
122 + * labels only inside their subboards. Mirrors the server-side rule.
123 + */
124 + fun appliesTo(subboardId: String?): Boolean =
125 + subboard_ids.isEmpty() || subboardId in subboard_ids
126 +}
116 127
117 128 @Serializable
118 129 data class Field(
@@ -321,6 +332,18 @@ data class CreateTransitionBody(
321 332 @Serializable
322 333 data class CreateLabelBody(val name: String, val color: String? = null)
323 334
335 +// Board labels carry an optional subboard scope. Posting an existing name
336 +// upserts (updates color + scope) server-side rather than failing.
337 +@Serializable
338 +data class BoardLabelBody(
339 + val name: String? = null,
340 + val color: String? = null,
341 + val subboard_ids: List<String>? = null,
342 +)
343 +
344 +@Serializable
345 +data class BoardLabelResponse(val label: Label)
346 +
324 347 @Serializable
325 348 data class CreateFieldBody(
326 349 val name: String,
modified android/app/src/main/java/org/neiam/waxx/app/data/WaxxClient.kt
+19 −0
@@ -178,6 +178,25 @@ interface WaxxApi {
178 178 @retrofit2.http.Path("id") id: String,
179 179 ): retrofit2.Response<Unit>
180 180
181 + // --- Board labels ---
182 +
183 + @retrofit2.http.POST("api/v1/boards/{id}/labels")
184 + suspend fun createBoardLabel(
185 + @retrofit2.http.Path("id") boardId: String,
186 + @retrofit2.http.Body body: BoardLabelBody,
187 + ): BoardLabelResponse
188 +
189 + @retrofit2.http.PATCH("api/v1/board_labels/{id}")
190 + suspend fun updateBoardLabel(
191 + @retrofit2.http.Path("id") id: String,
192 + @retrofit2.http.Body body: BoardLabelBody,
193 + ): BoardLabelResponse
194 +
195 + @retrofit2.http.DELETE("api/v1/board_labels/{id}")
196 + suspend fun deleteBoardLabel(
197 + @retrofit2.http.Path("id") id: String,
198 + ): retrofit2.Response<Unit>
199 +
181 200 // --- Phase 6: subboards ---
182 201
183 202 @retrofit2.http.POST("api/v1/boards/{id}/subboards")
modified android/app/src/main/java/org/neiam/waxx/app/ui/BoardSettingsScreen.kt
+175 −2
@@ -1,15 +1,19 @@
1 1 package org.neiam.waxx.app.ui
2 2
3 +import androidx.compose.foundation.background
3 4 import androidx.compose.foundation.layout.Arrangement
5 +import androidx.compose.foundation.layout.Box
4 6 import androidx.compose.foundation.layout.Column
5 7 import androidx.compose.foundation.layout.PaddingValues
6 8 import androidx.compose.foundation.layout.Row
7 9 import androidx.compose.foundation.layout.fillMaxSize
8 10 import androidx.compose.foundation.layout.fillMaxWidth
9 11 import androidx.compose.foundation.layout.padding
12 +import androidx.compose.foundation.layout.size
10 13 import androidx.compose.foundation.lazy.LazyColumn
11 14 import androidx.compose.foundation.lazy.items
12 15 import androidx.compose.foundation.lazy.itemsIndexed
16 +import androidx.compose.foundation.shape.CircleShape
13 17 import androidx.compose.foundation.text.KeyboardOptions
14 18 import androidx.compose.material.icons.Icons
15 19 import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -20,6 +24,7 @@ import androidx.compose.material.icons.filled.Delete
20 24 import androidx.compose.material3.AlertDialog
21 25 import androidx.compose.material3.Button
22 26 import androidx.compose.material3.Card
27 +import androidx.compose.material3.Checkbox
23 28 import androidx.compose.material3.ExperimentalMaterial3Api
24 29 import androidx.compose.material3.Icon
25 30 import androidx.compose.material3.IconButton
@@ -35,11 +40,14 @@ import androidx.compose.material3.TopAppBar
35 40 import androidx.compose.runtime.Composable
36 41 import androidx.compose.runtime.LaunchedEffect
37 42 import androidx.compose.runtime.getValue
43 +import androidx.compose.runtime.mutableStateListOf
38 44 import androidx.compose.runtime.mutableStateOf
39 45 import androidx.compose.runtime.remember
40 46 import androidx.compose.runtime.rememberCoroutineScope
41 47 import androidx.compose.runtime.setValue
48 +import androidx.compose.ui.Alignment
42 49 import androidx.compose.ui.Modifier
50 +import androidx.compose.ui.draw.clip
43 51 import androidx.compose.ui.platform.LocalClipboardManager
44 52 import androidx.compose.ui.text.AnnotatedString
45 53 import androidx.compose.ui.text.input.KeyboardType
@@ -47,9 +55,11 @@ import androidx.compose.ui.unit.dp
47 55 import kotlinx.coroutines.launch
48 56 import org.neiam.waxx.app.data.BoardDetail
49 57 import org.neiam.waxx.app.data.BoardInvite
58 +import org.neiam.waxx.app.data.BoardLabelBody
50 59 import org.neiam.waxx.app.data.BoardMembership
51 60 import org.neiam.waxx.app.data.CreateBoardInviteBody
52 61 import org.neiam.waxx.app.data.CreateSubboardBody
62 +import org.neiam.waxx.app.data.Label
53 63 import org.neiam.waxx.app.data.ReorderSubboardBody
54 64 import org.neiam.waxx.app.data.Subboard
55 65 import org.neiam.waxx.app.data.TokenStore
@@ -73,6 +83,7 @@ fun BoardSettingsScreen(
73 83 var board by remember { mutableStateOf<BoardDetail?>(null) }
74 84 var invites by remember { mutableStateOf<List<BoardInvite>>(emptyList()) }
75 85 var subboards by remember { mutableStateOf<List<Subboard>>(emptyList()) }
86 + var labels by remember { mutableStateOf<List<Label>>(emptyList()) }
76 87 var error by remember { mutableStateOf<String?>(null) }
77 88 var busy by remember { mutableStateOf(false) }
78 89
@@ -81,8 +92,9 @@ fun BoardSettingsScreen(
81 92 try {
82 93 val api = WaxxClient.authenticated(creds)
83 94 board = api.board(boardId).board
84 − subboards = runCatching { api.workflow(boardId).workflow.subboards }
85 − .getOrDefault(emptyList())
95 + val workflow = runCatching { api.workflow(boardId).workflow }.getOrNull()
96 + subboards = workflow?.subboards.orEmpty()
97 + labels = workflow?.labels.orEmpty()
86 98 invites = runCatching { api.boardInvites(boardId).invites }.getOrDefault(emptyList())
87 99 } catch (e: Exception) {
88 100 error = e.message
@@ -125,6 +137,7 @@ fun BoardSettingsScreen(
125 137 Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("Members") })
126 138 Tab(selected = tab == 2, onClick = { tab = 2 }, text = { Text("Invites") })
127 139 Tab(selected = tab == 3, onClick = { tab = 3 }, text = { Text("Rows") })
140 + Tab(selected = tab == 4, onClick = { tab = 4 }, text = { Text("Labels") })
128 141 }
129 142
130 143 val isOwner = board?.role == "owner"
@@ -205,6 +218,28 @@ fun BoardSettingsScreen(
205 218 mutate { deleteSubboard(id) }
206 219 },
207 220 )
221 +
222 + 4 -> LabelsTab(
223 + labels = labels,
224 + subboards = subboards,
225 + isOwner = isOwner,
226 + busy = busy,
227 + onCreate = { name, color, subboardIds ->
228 + mutate {
229 + createBoardLabel(
230 + boardId = boardId,
231 + body = BoardLabelBody(
232 + name = name,
233 + color = color,
234 + subboard_ids = subboardIds,
235 + ),
236 + )
237 + }
238 + },
239 + onDelete = { id ->
240 + mutate { deleteBoardLabel(id) }
241 + },
242 + )
208 243 }
209 244
210 245 error?.let {
@@ -429,6 +464,144 @@ private fun SubboardsTab(
429 464 }
430 465 }
431 466
467 +@Composable
468 +private fun LabelsTab(
469 + labels: List<Label>,
470 + subboards: List<Subboard>,
471 + isOwner: Boolean,
472 + busy: Boolean,
473 + onCreate: (name: String, color: String?, subboardIds: List<String>) -> Unit,
474 + onDelete: (String) -> Unit,
475 +) {
476 + var newName by remember { mutableStateOf("") }
477 + var newColor by remember { mutableStateOf("") }
478 + val selectedSubboards = remember { mutableStateListOf<String>() }
479 +
480 + LazyColumn(
481 + modifier = Modifier.fillMaxSize(),
482 + contentPadding = PaddingValues(16.dp),
483 + verticalArrangement = Arrangement.spacedBy(8.dp),
484 + ) {
485 + if (isOwner) {
486 + item {
487 + Card(modifier = Modifier.fillMaxWidth()) {
488 + Column(
489 + modifier = Modifier.padding(12.dp),
490 + verticalArrangement = Arrangement.spacedBy(8.dp),
491 + ) {
492 + Text("Add a label", style = MaterialTheme.typography.titleSmall)
493 + Text(
494 + "Leave all rows unchecked for a board-wide label, or pick rows to " +
495 + "scope it to them. Re-adding an existing name updates its color and scope.",
496 + style = MaterialTheme.typography.bodySmall,
497 + color = MaterialTheme.colorScheme.onSurfaceVariant,
498 + )
499 + OutlinedTextField(
500 + value = newName,
501 + onValueChange = { newName = it },
502 + label = { Text("Name") },
503 + singleLine = true,
504 + modifier = Modifier.fillMaxWidth(),
505 + )
506 + OutlinedTextField(
507 + value = newColor,
508 + onValueChange = { newColor = it },
509 + label = { Text("Color hex (optional), e.g. #ef4444") },
510 + singleLine = true,
511 + modifier = Modifier.fillMaxWidth(),
512 + )
513 + if (subboards.isNotEmpty()) {
514 + Text(
515 + "Limit to rows",
516 + style = MaterialTheme.typography.labelMedium,
517 + )
518 + subboards.forEach { sb ->
519 + Row(
520 + verticalAlignment = Alignment.CenterVertically,
521 + horizontalArrangement = Arrangement.spacedBy(4.dp),
522 + ) {
523 + Checkbox(
524 + checked = sb.id in selectedSubboards,
525 + enabled = !busy,
526 + onCheckedChange = { on ->
527 + if (on) selectedSubboards.add(sb.id)
528 + else selectedSubboards.remove(sb.id)
529 + },
530 + )
531 + Text(sb.name, style = MaterialTheme.typography.bodyMedium)
532 + }
533 + }
534 + }
535 + Button(
536 + enabled = !busy && newName.isNotBlank(),
537 + onClick = {
538 + onCreate(
539 + newName.trim(),
540 + newColor.trim().ifBlank { null },
541 + selectedSubboards.toList(),
542 + )
543 + newName = ""
544 + newColor = ""
545 + selectedSubboards.clear()
546 + },
547 + ) { Text("Add label") }
548 + }
549 + }
550 + }
551 + }
552 +
553 + items(labels, key = { it.id }) { label ->
554 + Card(modifier = Modifier.fillMaxWidth()) {
555 + Row(
556 + modifier = Modifier.fillMaxWidth().padding(12.dp),
557 + horizontalArrangement = Arrangement.spacedBy(8.dp),
558 + verticalAlignment = Alignment.CenterVertically,
559 + ) {
560 + parseStageColor(label.color)?.let { c ->
561 + Box(
562 + modifier = Modifier
563 + .size(16.dp)
564 + .clip(CircleShape)
565 + .background(c),
566 + )
567 + }
568 + Column(modifier = Modifier.weight(1f)) {
569 + Text(label.name, style = MaterialTheme.typography.bodyMedium)
570 + val scope = if (label.subboard_ids.isEmpty()) {
571 + "All rows"
572 + } else {
573 + subboards
574 + .filter { it.id in label.subboard_ids }
575 + .joinToString { it.name }
576 + .ifBlank { "Scoped rows" }
577 + }
578 + Text(
579 + scope,
580 + style = MaterialTheme.typography.labelSmall,
581 + color = MaterialTheme.colorScheme.onSurfaceVariant,
582 + )
583 + }
584 + if (isOwner) {
585 + OutlinedButton(enabled = !busy, onClick = { onDelete(label.id) }) {
586 + Icon(Icons.Default.Delete, contentDescription = null); Text(" Delete")
587 + }
588 + }
589 + }
590 + }
591 + }
592 +
593 + if (labels.isEmpty()) {
594 + item {
595 + Text(
596 + "No labels yet.",
597 + style = MaterialTheme.typography.bodySmall,
598 + color = MaterialTheme.colorScheme.onSurfaceVariant,
599 + )
600 + }
601 + }
602 + }
603 +}
604 +
432 605 @Composable
433 606 private fun MemberRow(
434 607 membership: BoardMembership,
modified android/app/src/main/java/org/neiam/waxx/app/ui/CardSheet.kt
+8 −2
@@ -240,11 +240,17 @@ fun CardSheet(
240 240 }
241 241 }
242 242
243 − if (labelDefs.isNotEmpty()) {
243 + // Only offer labels in scope for this card's row; a label
244 + // already on the card stays visible so it can be removed
245 + // even after being scoped away. Mirrors the server-side rule.
246 + val pickableLabels = labelDefs.filter {
247 + it.appliesTo(subboardId) || it.id in labelIds
248 + }
249 + if (pickableLabels.isNotEmpty()) {
244 250 item { SectionHeader("Labels") }
245 251 item {
246 252 FlowRowChips {
247 − labelDefs.forEach { label ->
253 + pickableLabels.forEach { label ->
248 254 val selected = label.id in labelIds
249 255 FilterChip(
250 256 selected = selected,

Parents: 0b2ca62