neiam
/waxx
waxx
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
updated android
cf43072 · gmorell · 2026-06-18 22:42
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