show card backgrounds on android board tiles

f4ed124 · gmorell · 2026-06-24 02:17

30 files +138 -12
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