neiam
/waxx
waxx
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
show card backgrounds on android board tiles
f4ed124 · gmorell · 2026-06-24 02:17
Message
{commit_body(@commit)}
Files changed
modified
android/app/build.gradle.kts
+1
−0
@@ -68,4 +68,5 @@ dependencies {
| 68 | 68 | |
| 69 | 69 | implementation(libs.zxing.android.embedded) |
| 70 | 70 | implementation(libs.phoenix.client) |
| 71 | + implementation(libs.coil.compose) | |
| 71 | 72 | } |
modified
android/app/src/main/java/org/neiam/waxx/app/data/Models.kt
+2
−0
@@ -157,6 +157,8 @@ data class CardSummary(
| 157 | 157 | val assignee_ids: List<String> = emptyList(), |
| 158 | 158 | val label_ids: List<String> = emptyList(), |
| 159 | 159 | val field_values: List<CardFieldValue> = emptyList(), |
| 160 | + // Set when the card has a background image; also cache-busts the image URL. | |
| 161 | + val background_version: Long? = null, | |
| 160 | 162 | ) |
| 161 | 163 | |
| 162 | 164 | @Serializable |
modified
android/app/src/main/java/org/neiam/waxx/app/ui/BoardScreen.kt
+50
−7
@@ -20,7 +20,11 @@ import androidx.compose.foundation.lazy.items
| 20 | 20 | import androidx.compose.foundation.rememberScrollState |
| 21 | 21 | import androidx.compose.foundation.shape.RoundedCornerShape |
| 22 | 22 | import androidx.compose.ui.input.pointer.pointerInput |
| 23 | +import androidx.compose.ui.layout.ContentScale | |
| 23 | 24 | import androidx.compose.ui.layout.onGloballyPositioned |
| 25 | +import androidx.compose.ui.platform.LocalContext | |
| 26 | +import coil.compose.AsyncImage | |
| 27 | +import coil.request.ImageRequest | |
| 24 | 28 | import androidx.compose.ui.layout.positionInWindow |
| 25 | 29 | import androidx.compose.ui.geometry.Offset |
| 26 | 30 | import androidx.compose.ui.geometry.Rect |
@@ -237,6 +241,7 @@ fun BoardScreen(
| 237 | 241 | Kanban( |
| 238 | 242 | workflow = s.workflow, |
| 239 | 243 | cards = s.cards, |
| 244 | + creds = creds, | |
| 240 | 245 | onOpenCard = onOpenCard, |
| 241 | 246 | dragState = dragState, |
| 242 | 247 | onDropFinished = ::handleDrop, |
@@ -335,6 +340,7 @@ private fun Banner(text: String, color: androidx.compose.ui.graphics.Color) {
| 335 | 340 | private fun Kanban( |
| 336 | 341 | workflow: Workflow, |
| 337 | 342 | cards: List<CardSummary>, |
| 343 | + creds: TokenStore.Credentials?, | |
| 338 | 344 | onOpenCard: (CardSummary) -> Unit, |
| 339 | 345 | dragState: DragState, |
| 340 | 346 | onDropFinished: () -> Unit, |
@@ -390,6 +396,7 @@ private fun Kanban(
| 390 | 396 | showStageLabel = true, |
| 391 | 397 | cards = cardsByCell[stage.id to null].orEmpty(), |
| 392 | 398 | workflow = workflow, |
| 399 | + creds = creds, | |
| 393 | 400 | dragState = dragState, |
| 394 | 401 | onOpenCard = onOpenCard, |
| 395 | 402 | onDropFinished = onDropFinished, |
@@ -453,6 +460,7 @@ private fun Kanban(
| 453 | 460 | showStageLabel = false, |
| 454 | 461 | cards = cardsByCell[stage.id to sb?.id].orEmpty(), |
| 455 | 462 | workflow = workflow, |
| 463 | + creds = creds, | |
| 456 | 464 | dragState = dragState, |
| 457 | 465 | onOpenCard = onOpenCard, |
| 458 | 466 | onDropFinished = onDropFinished, |
@@ -472,6 +480,7 @@ private fun StageCell(
| 472 | 480 | showStageLabel: Boolean, |
| 473 | 481 | cards: List<CardSummary>, |
| 474 | 482 | workflow: Workflow, |
| 483 | + creds: TokenStore.Credentials?, | |
| 475 | 484 | dragState: DragState, |
| 476 | 485 | onOpenCard: (CardSummary) -> Unit, |
| 477 | 486 | onDropFinished: () -> Unit, |
@@ -553,6 +562,7 @@ private fun StageCell(
| 553 | 562 | card = c, |
| 554 | 563 | fromStageId = stage.id, |
| 555 | 564 | fromSubboardId = subboard?.id, |
| 565 | + creds = creds, | |
| 556 | 566 | dragState = dragState, |
| 557 | 567 | onClick = { onOpenCard(c) }, |
| 558 | 568 | onDropFinished = onDropFinished, |
@@ -569,6 +579,7 @@ private fun CardChip(
| 569 | 579 | card: CardSummary, |
| 570 | 580 | fromStageId: String, |
| 571 | 581 | fromSubboardId: String?, |
| 582 | + creds: TokenStore.Credentials?, | |
| 572 | 583 | dragState: DragState, |
| 573 | 584 | onClick: () -> Unit, |
| 574 | 585 | onDropFinished: () -> Unit, |
@@ -576,6 +587,17 @@ private fun CardChip(
| 576 | 587 | var chipOrigin by remember { mutableStateOf(Offset.Zero) } |
| 577 | 588 | val isMe = dragState.session?.card?.id == card.id |
| 578 | 589 | |
| 590 | + // Background image, fetched lazily by Coil from the authenticated API. The | |
| 591 | + // `?v=` version cache-busts when it changes. Bytes never ride along with | |
| 592 | + // the board's card list — only the version flag does. | |
| 593 | + val backgroundModel = remember(creds, card.id, card.background_version) { | |
| 594 | + if (creds != null && card.background_version != null) { | |
| 595 | + "${creds.baseUrl}/api/v1/cards/${card.id}/background?v=${card.background_version}" | |
| 596 | + } else { | |
| 597 | + null | |
| 598 | + } | |
| 599 | + } | |
| 600 | + | |
| 579 | 601 | Card( |
| 580 | 602 | modifier = Modifier |
| 581 | 603 | .fillMaxWidth() |
@@ -600,15 +622,36 @@ private fun CardChip(
| 600 | 622 | .clickable(onClick = onClick) |
| 601 | 623 | .then(if (isMe) Modifier.border(2.dp, Color(0xFF40A0FF), RoundedCornerShape(6.dp)) else Modifier), |
| 602 | 624 | ) { |
| 603 | − Column(modifier = Modifier.padding(8.dp)) { | |
| 604 | − Text(card.title, style = MaterialTheme.typography.bodyMedium) | |
| 605 | − if (!card.description.isNullOrBlank()) { | |
| 606 | − Text( | |
| 607 | − card.description.take(80), | |
| 608 | − style = MaterialTheme.typography.bodySmall, | |
| 609 | − color = MaterialTheme.colorScheme.onSurfaceVariant, | |
| 625 | + Box(modifier = Modifier.fillMaxWidth()) { | |
| 626 | + if (backgroundModel != null) { | |
| 627 | + AsyncImage( | |
| 628 | + model = ImageRequest.Builder(LocalContext.current) | |
| 629 | + .data(backgroundModel) | |
| 630 | + .addHeader("Authorization", "Bearer ${creds!!.token}") | |
| 631 | + .crossfade(true) | |
| 632 | + .build(), | |
| 633 | + contentDescription = null, | |
| 634 | + contentScale = ContentScale.Crop, | |
| 635 | + modifier = Modifier.matchParentSize(), | |
| 636 | + ) | |
| 637 | + // Blend the photo into the surface so the text stays legible — | |
| 638 | + // a touch stronger than the modal since tiles are small. | |
| 639 | + Box( | |
| 640 | + modifier = Modifier | |
| 641 | + .matchParentSize() | |
| 642 | + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.82f)), | |
| 610 | 643 | ) |
| 611 | 644 | } |
| 645 | + Column(modifier = Modifier.padding(8.dp)) { | |
| 646 | + Text(card.title, style = MaterialTheme.typography.bodyMedium) | |
| 647 | + if (!card.description.isNullOrBlank()) { | |
| 648 | + Text( | |
| 649 | + card.description.take(80), | |
| 650 | + style = MaterialTheme.typography.bodySmall, | |
| 651 | + color = MaterialTheme.colorScheme.onSurfaceVariant, | |
| 652 | + ) | |
| 653 | + } | |
| 654 | + } | |
| 612 | 655 | } |
| 613 | 656 | } |
| 614 | 657 | } |
modified
android/app/src/main/java/org/neiam/waxx/app/ui/CardSheet.kt
+1
−1
@@ -149,7 +149,7 @@ fun CardSheet(
| 149 | 149 | Box( |
| 150 | 150 | modifier = Modifier |
| 151 | 151 | .matchParentSize() |
| 152 | − .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.72f)), | |
| 152 | + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.88f)), | |
| 153 | 153 | ) |
| 154 | 154 | } |
| 155 | 155 | LazyColumn( |
modified
android/gradle/libs.versions.toml
+3
−0
@@ -15,6 +15,7 @@ security-crypto = "1.1.0-alpha06"
| 15 | 15 | zxing-embedded = "4.3.0" |
| 16 | 16 | coroutines = "1.8.1" |
| 17 | 17 | phoenix-client = "0.3.4" |
| 18 | +coil = "2.7.0" | |
| 18 | 19 | |
| 19 | 20 | [libraries] |
| 20 | 21 | androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } |
@@ -42,6 +43,8 @@ zxing-android-embedded = { group = "com.journeyapps", name = "zxing-android-embe
| 42 | 43 | |
| 43 | 44 | phoenix-client = { group = "com.github.dsrees", name = "JavaPhoenixClient", version.ref = "phoenix-client" } |
| 44 | 45 | |
| 46 | +coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } | |
| 47 | + | |
| 45 | 48 | [plugins] |
| 46 | 49 | android-application = { id = "com.android.application", version.ref = "agp" } |
| 47 | 50 | kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } |
modified
lib/waxx_web/api/v1/board_controller.ex
+1
−1
@@ -64,7 +64,7 @@ defmodule WaxxWeb.Api.V1.BoardController do
| 64 | 64 | with {:ok, _} <- fetch_board(id, user) do |
| 65 | 65 | board = Kanban.get_board_with_workflow!(id, user) |
| 66 | 66 | cards = Kanban.list_cards(board) |
| 67 | − json(conn, BoardJSON.cards(cards)) | |
| 67 | + json(conn, BoardJSON.cards(cards, Kanban.background_versions(board))) | |
| 68 | 68 | end |
| 69 | 69 | end |
| 70 | 70 |
modified
lib/waxx_web/api/v1/board_json.ex
+6
−3
@@ -142,8 +142,8 @@ defmodule WaxxWeb.Api.V1.BoardJSON do
| 142 | 142 | |
| 143 | 143 | ## Cards --------------------------------------------------------------- |
| 144 | 144 | |
| 145 | − def cards(cards) when is_list(cards) do | |
| 146 | − %{cards: Enum.map(cards, &card/1)} | |
| 145 | + def cards(cards, versions \\ %{}) when is_list(cards) do | |
| 146 | + %{cards: Enum.map(cards, &card(&1, versions))} | |
| 147 | 147 | end |
| 148 | 148 | |
| 149 | 149 | @doc "Single-card envelope used by mutation endpoints." |
@@ -339,7 +339,7 @@ defmodule WaxxWeb.Api.V1.BoardJSON do
| 339 | 339 | } |
| 340 | 340 | end |
| 341 | 341 | |
| 342 | − def card(%Card{} = c) do | |
| 342 | + def card(%Card{} = c, versions \\ %{}) do | |
| 343 | 343 | %{ |
| 344 | 344 | id: c.id, |
| 345 | 345 | title: c.title, |
@@ -352,6 +352,9 @@ defmodule WaxxWeb.Api.V1.BoardJSON do
| 352 | 352 | assignee_ids: assignee_ids(c.assignees), |
| 353 | 353 | label_ids: label_ids(c.labels), |
| 354 | 354 | field_values: field_values(c.field_values), |
| 355 | + # Present only when the card has a background image; the client uses it | |
| 356 | + # to flag the tile and cache-bust GET /cards/:id/background. | |
| 357 | + background_version: Map.get(versions, c.id), | |
| 355 | 358 | inserted_at: c.inserted_at, |
| 356 | 359 | updated_at: c.updated_at |
| 357 | 360 | } |
modified
lib/waxx_web/api/v1/card_controller.ex
+20
−0
@@ -132,6 +132,26 @@ defmodule WaxxWeb.Api.V1.CardController do
| 132 | 132 | end |
| 133 | 133 | end |
| 134 | 134 | |
| 135 | + ## Background image bytes -------------------------------------------- | |
| 136 | + # | |
| 137 | + # Served separately so the board's card list never carries image bytes — | |
| 138 | + # tiles reference this lazily and cache-bust on the `?v=` version from the | |
| 139 | + # list payload's `background_version`. | |
| 140 | + def background(conn, %{"id" => id}) do | |
| 141 | + user = conn.assigns.current_scope.user | |
| 142 | + | |
| 143 | + case Kanban.fetch_card_background_for_user(id, user) do | |
| 144 | + {:ok, bg} -> | |
| 145 | + conn | |
| 146 | + |> put_resp_header("cache-control", "private, max-age=31536000, immutable") | |
| 147 | + |> put_resp_content_type(bg.content_type, nil) | |
| 148 | + |> send_resp(200, bg.image_data) | |
| 149 | + | |
| 150 | + :error -> | |
| 151 | + {:error, :not_found} | |
| 152 | + end | |
| 153 | + end | |
| 154 | + | |
| 135 | 155 | ## Labels ------------------------------------------------------------- |
| 136 | 156 | |
| 137 | 157 | def toggle_label(conn, %{"id" => card_id, "label_id" => label_id}) do |
modified
lib/waxx_web/router.ex
+1
−0
@@ -131,6 +131,7 @@ defmodule WaxxWeb.Router do
| 131 | 131 | delete "/cards/:id", CardController, :delete |
| 132 | 132 | |
| 133 | 133 | get "/cards/:id", CardController, :show |
| 134 | + get "/cards/:id/background", CardController, :background | |
| 134 | 135 | post "/cards/:id/labels/:label_id/toggle", CardController, :toggle_label |
| 135 | 136 | put "/cards/:id/fields/:field_id", CardController, :set_field |
| 136 | 137 | post "/cards/:id/assignees", CardController, :add_assignee |
modified
test/waxx_web/api/v1/card_controller_test.exs
+53
−0
@@ -169,6 +169,59 @@ defmodule WaxxWeb.Api.V1.CardControllerTest do
| 169 | 169 | end |
| 170 | 170 | end |
| 171 | 171 | |
| 172 | + describe "GET /api/v1/boards/:board_id/cards (background_version)" do | |
| 173 | + @png_data_url "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" | |
| 174 | + | |
| 175 | + test "lists background_version only for cards that have a background", %{conn: conn} do | |
| 176 | + %{token: token, board: board, user: user} = setup_owner() | |
| 177 | + with_bg = card_fixture(board, user, %{"title" => "art"}) | |
| 178 | + without_bg = card_fixture(board, user, %{"title" => "plain"}) | |
| 179 | + {:ok, _} = Kanban.set_card_background(with_bg, @png_data_url) | |
| 180 | + | |
| 181 | + conn = conn |> auth(token) |> get(~p"/api/v1/boards/#{board.id}/cards") | |
| 182 | + cards = json_response(conn, 200)["cards"] | |
| 183 | + | |
| 184 | + assert %{"background_version" => v} = Enum.find(cards, &(&1["id"] == with_bg.id)) | |
| 185 | + assert is_integer(v) | |
| 186 | + assert %{"background_version" => nil} = Enum.find(cards, &(&1["id"] == without_bg.id)) | |
| 187 | + end | |
| 188 | + end | |
| 189 | + | |
| 190 | + describe "GET /api/v1/cards/:id/background" do | |
| 191 | + @png_data_url "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" | |
| 192 | + | |
| 193 | + test "serves the image bytes with its content type to a member", %{conn: conn} do | |
| 194 | + %{token: token, board: board, user: user} = setup_owner() | |
| 195 | + card = card_fixture(board, user) | |
| 196 | + {:ok, bg} = Kanban.set_card_background(card, @png_data_url) | |
| 197 | + | |
| 198 | + conn = conn |> auth(token) |> get(~p"/api/v1/cards/#{card.id}/background") | |
| 199 | + | |
| 200 | + assert response(conn, 200) == bg.image_data | |
| 201 | + assert get_resp_header(conn, "content-type") |> hd() =~ "image/png" | |
| 202 | + end | |
| 203 | + | |
| 204 | + test "404 when the card has no background", %{conn: conn} do | |
| 205 | + %{token: token, board: board, user: user} = setup_owner() | |
| 206 | + card = card_fixture(board, user) | |
| 207 | + | |
| 208 | + conn = conn |> auth(token) |> get(~p"/api/v1/cards/#{card.id}/background") | |
| 209 | + assert json_response(conn, 404)["error"]["code"] == "not_found" | |
| 210 | + end | |
| 211 | + | |
| 212 | + test "404 for a non-member", %{conn: conn} do | |
| 213 | + %{board: board, user: user} = setup_owner() | |
| 214 | + card = card_fixture(board, user) | |
| 215 | + {:ok, _} = Kanban.set_card_background(card, @png_data_url) | |
| 216 | + | |
| 217 | + stranger = confirmed_user_fixture() | |
| 218 | + token = api_token_fixture(stranger) | |
| 219 | + | |
| 220 | + conn = conn |> auth(token) |> get(~p"/api/v1/cards/#{card.id}/background") | |
| 221 | + assert json_response(conn, 404)["error"]["code"] == "not_found" | |
| 222 | + end | |
| 223 | + end | |
| 224 | + | |
| 172 | 225 | describe "DELETE /api/v1/cards/:id" do |
| 173 | 226 | test "deletes the card", %{conn: conn} do |
| 174 | 227 | %{token: token, board: board, user: user} = setup_owner() |
Parents: d372e8b