Android client phase 2: canvas list and read-only render

99f6896 · Gabriel Morell · 2026-09-14 22:30

35 files +12720 -36
Message
{commit_body(@commit)}

Files changed

modified android/README.md
+31 −6
@@ -6,9 +6,9 @@ Native Android client for [Diogramos](../). Talks to the JSON API at
6 6 typography, so the two feel like one product family.
7 7
8 8 The design doc and phase plan live in [docs/android.md](../docs/android.md).
9 −Shipped so far: **phase 1b** — the scaffold and both auth paths. The canvas
10 −list is a placeholder that proves the credentials work; rendering arrives
11 −in phase 2.
9 +Shipped so far: **phase 2** — auth, the canvas list, and a read-only
10 +render of a canvas with pan and zoom. Realtime is phase 3; editing is
11 +phase 4.
12 12
13 13 ## Stack
14 14
@@ -115,6 +115,27 @@ authored in `forest` render in `forest` colors while the app chrome stays
115 115 in whatever the user picked — and it is why these values are copied from
116 116 the CSS rather than eyeballed.
117 117
118 +## Connector geometry
119 +
120 +`canvas/ConnectorGeometry.kt` is a port of
121 +`lib/diogramos/diagrams/connector_geometry.ex`. Routing has to run on the
122 +device — a connector re-routes on every frame of a drag — so the same
123 +maths necessarily exists twice, and two copies drift silently: nothing
124 +throws, the diagram is just subtly wrong on one side.
125 +
126 +So the Elixir module is the authority and the port is pinned to it:
127 +
128 +```sh
129 +mix diogramos.android_fixtures # from the repo root
130 +```
131 +
132 +dumps the server's output for 360 documents into
133 +`app/src/test/resources/connector_geometry.json`, and
134 +`ConnectorGeometryTest` asserts this port reproduces every one of them,
135 +SVG path string included. After a deliberate change to the Elixir side,
136 +regenerate and commit; a failure for any other reason means the port is
137 +wrong.
138 +
118 139 ## Tests
119 140
120 141 `app/src/test/` holds plain JVM tests — no emulator, no Robolectric. That
@@ -125,6 +146,10 @@ directly.
125 146
126 147 ## Not yet shipped
127 148
128 −Everything from phase 2 on: canvas rendering, the realtime channel,
129 −editing, export, the launcher icon, and the release/F-Droid pipeline.
130 −See [docs/android.md](../docs/android.md).
149 +The realtime channel (phase 3), editing (phase 4), export and the theme
150 +picker (phase 5), and the launcher icon and release/F-Droid pipeline
151 +(phase 6). See [docs/android.md](../docs/android.md).
152 +
153 +Nothing has yet been seen running on a screen: the unit tests cover the
154 +geometry, the parser and the viewport maths, but no emulator or device
155 +was available to confirm a rendered diagram looks right.
modified android/app/src/main/java/org/neiam/diogramos/app/MainActivity.kt
+16 −1
@@ -13,13 +13,16 @@ import androidx.compose.runtime.mutableStateOf
13 13 import androidx.compose.runtime.remember
14 14 import androidx.compose.runtime.setValue
15 15 import androidx.compose.ui.Modifier
16 +import androidx.navigation.NavType
16 17 import androidx.navigation.compose.NavHost
17 18 import androidx.navigation.compose.composable
18 19 import androidx.navigation.compose.rememberNavController
20 +import androidx.navigation.navArgument
19 21 import org.neiam.diogramos.app.auth.LoginScreen
20 22 import org.neiam.diogramos.app.auth.MagicLinkScreen
21 23 import org.neiam.diogramos.app.auth.PairScreen
22 24 import org.neiam.diogramos.app.canvas.CanvasListScreen
25 +import org.neiam.diogramos.app.canvas.CanvasScreen
23 26 import org.neiam.diogramos.app.data.PairPayload
24 27 import org.neiam.diogramos.app.data.ThemeStore
25 28 import org.neiam.diogramos.app.data.TokenStore
@@ -31,7 +34,7 @@ import org.neiam.diogramos.app.ui.theme.appThemeForKey
31 34 /**
32 35 * Single-activity host.
33 36 *
34 − * Routes: login → pair | magic → canvases.
37 + * Routes: login → pair | magic → canvases → canvas/{slug}.
35 38 *
36 39 * Deep links, both dispatched here rather than through navigation deep
37 40 * links, because the URL has to be parsed before we know which screen it
@@ -130,6 +133,7 @@ fun DiogramosApp(tokens: TokenStore, initialDeepLink: DeepLink?) {
130 133 composable("canvases") {
131 134 CanvasListScreen(
132 135 tokens = tokens,
136 + onOpen = { slug -> nav.navigate("canvas/$slug") },
133 137 onSignOut = {
134 138 nav.navigate("login") {
135 139 popUpTo(0) { inclusive = true }
@@ -137,5 +141,16 @@ fun DiogramosApp(tokens: TokenStore, initialDeepLink: DeepLink?) {
137 141 },
138 142 )
139 143 }
144 +
145 + composable(
146 + "canvas/{slug}",
147 + arguments = listOf(navArgument("slug") { type = NavType.StringType }),
148 + ) { entry ->
149 + CanvasScreen(
150 + slug = entry.arguments?.getString("slug").orEmpty(),
151 + tokens = tokens,
152 + onBack = { if (!nav.popBackStack()) nav.navigate("canvases") },
153 + )
154 + }
140 155 }
141 156 }
added android/app/src/main/java/org/neiam/diogramos/app/canvas/CanvasDocument.kt
+258 −0
@@ -0,0 +1,258 @@
1 +package org.neiam.diogramos.app.canvas
2 +
3 +import kotlinx.serialization.json.JsonArray
4 +import kotlinx.serialization.json.JsonElement
5 +import kotlinx.serialization.json.JsonObject
6 +import kotlinx.serialization.json.JsonPrimitive
7 +import kotlinx.serialization.json.booleanOrNull
8 +import kotlinx.serialization.json.doubleOrNull
9 +import kotlinx.serialization.json.jsonObject
10 +import kotlinx.serialization.json.jsonPrimitive
11 +
12 +/**
13 + * A read model over the canvas document.
14 + *
15 + * The wire format stays the authority: [raw] is the untouched JSON the
16 + * server sent, and these types are a view onto it. Ops are applied to the
17 + * JSON, not to these, so a field this client doesn't know about survives
18 + * a round trip instead of being silently dropped — which matters because
19 + * an `update_element` patch is partial and the server deep-merges it.
20 + *
21 + * Anything malformed is skipped rather than thrown on. The server
22 + * validates every op through `Diogramos.Diagrams.Document`, so a shape
23 + * that fails to parse here means this client is older than the document;
24 + * dropping one shape beats refusing to draw the diagram.
25 + */
26 +data class CanvasDocument(
27 + val raw: JsonObject,
28 + val elements: Map<String, Element>,
29 + val order: List<String>,
30 + val connectors: Map<String, Connector>,
31 +) {
32 + /** Painting order, back to front, skipping ids with no element. */
33 + val paintOrder: List<Element>
34 + get() = order.mapNotNull { elements[it] }
35 +
36 + val bounds: Rect?
37 + get() = paintOrder.map { it.bbox }.reduceOrNull { acc, r -> acc.union(r) }
38 +
39 + companion object {
40 + val EMPTY = CanvasDocument(JsonObject(emptyMap()), emptyMap(), emptyList(), emptyMap())
41 +
42 + fun parse(json: JsonObject?): CanvasDocument {
43 + json ?: return EMPTY
44 +
45 + val elements = (json["elements"] as? JsonObject)
46 + ?.mapNotNull { (id, value) ->
47 + (value as? JsonObject)?.let { Element.parse(id, it) }?.let { id to it }
48 + }
49 + ?.toMap()
50 + .orEmpty()
51 +
52 + val order = (json["order"] as? JsonArray)
53 + ?.mapNotNull { it.asStringOrNull() }
54 + // An element missing from `order` would never be painted,
55 + // so anything absent is appended rather than lost.
56 + ?.filter { elements.containsKey(it) }
57 + .orEmpty()
58 + .let { ordered -> ordered + elements.keys.filterNot { it in ordered } }
59 +
60 + val connectors = (json["connectors"] as? JsonObject)
61 + ?.mapNotNull { (id, value) ->
62 + (value as? JsonObject)?.let { Connector.parse(id, it) }?.let { id to it }
63 + }
64 + ?.toMap()
65 + .orEmpty()
66 +
67 + return CanvasDocument(json, elements, order, connectors)
68 + }
69 + }
70 +}
71 +
72 +data class Rect(val x: Double, val y: Double, val w: Double, val h: Double) {
73 + val centerX: Double get() = x + w / 2
74 + val centerY: Double get() = y + h / 2
75 +
76 + fun union(other: Rect): Rect {
77 + val left = minOf(x, other.x)
78 + val top = minOf(y, other.y)
79 + val right = maxOf(x + w, other.x + other.w)
80 + val bottom = maxOf(y + h, other.y + other.h)
81 + return Rect(left, top, right - left, bottom - top)
82 + }
83 +
84 + fun contains(px: Double, py: Double): Boolean =
85 + px >= x && px <= x + w && py >= y && py <= y + h
86 +}
87 +
88 +/** The four element types `Document` accepts. */
89 +enum class ElementType(val wire: String) {
90 + RECT("rect"),
91 + ROUNDED("rounded"),
92 + CIRCLE("circle"),
93 + TEXT("text");
94 +
95 + companion object {
96 + fun from(wire: String?): ElementType? = entries.firstOrNull { it.wire == wire }
97 + }
98 +}
99 +
100 +data class Element(
101 + val id: String,
102 + val type: ElementType,
103 + val bbox: Rect,
104 + val label: String,
105 + val style: Style,
106 + /** Circles keep their radius: drawing one as its bounding box is wrong. */
107 + val radius: Double? = null,
108 +) {
109 + companion object {
110 + fun parse(id: String, json: JsonObject): Element? {
111 + val type = ElementType.from(json["type"].asStringOrNull()) ?: return null
112 +
113 + val bbox: Rect
114 + var radius: Double? = null
115 +
116 + if (type == ElementType.CIRCLE) {
117 + val cx = json["cx"].asDoubleOrNull() ?: return null
118 + val cy = json["cy"].asDoubleOrNull() ?: return null
119 + val r = json["r"].asDoubleOrNull()?.takeIf { it > 0 } ?: return null
120 + radius = r
121 + bbox = Rect(cx - r, cy - r, 2 * r, 2 * r)
122 + } else {
123 + val x = json["x"].asDoubleOrNull() ?: return null
124 + val y = json["y"].asDoubleOrNull() ?: return null
125 + val w = json["w"].asDoubleOrNull()?.takeIf { it > 0 } ?: return null
126 + val h = json["h"].asDoubleOrNull()?.takeIf { it > 0 } ?: return null
127 + bbox = Rect(x, y, w, h)
128 + }
129 +
130 + return Element(
131 + id = id,
132 + type = type,
133 + bbox = bbox,
134 + label = json["label"].asStringOrNull().orEmpty(),
135 + style = Style.parse(json["style"] as? JsonObject),
136 + radius = radius,
137 + )
138 + }
139 + }
140 +}
141 +
142 +/**
143 + * Colors are daisyUI *token names*, not hex — they resolve against the
144 + * canvas's theme at draw time (see `AppTheme.token`). A null fill means
145 + * the server never set one.
146 + */
147 +data class Style(
148 + val fill: String? = null,
149 + val stroke: String? = null,
150 + val shadow: Boolean = false,
151 + val dash: String = "solid",
152 + val strokeWidth: Double = 1.0,
153 + val fontFamily: String? = null,
154 + val fontSize: Double? = null,
155 + val fontBold: Boolean = false,
156 + val fontItalic: Boolean = false,
157 + val labelPosition: String = "center",
158 + val labelSize: String = "md",
159 + val labelColor: String? = null,
160 + val labelFontFamily: String? = null,
161 + val labelBold: Boolean = false,
162 + val labelItalic: Boolean = false,
163 +) {
164 + companion object {
165 + fun parse(json: JsonObject?): Style {
166 + json ?: return Style()
167 + return Style(
168 + fill = json["fill"].asStringOrNull(),
169 + stroke = json["stroke"].asStringOrNull(),
170 + shadow = json["shadow"].asBooleanOrNull() ?: false,
171 + dash = json["dash"].asStringOrNull() ?: "solid",
172 + strokeWidth = json["stroke_width"].asDoubleOrNull() ?: 1.0,
173 + fontFamily = json["font_family"].asStringOrNull(),
174 + fontSize = json["font_size"].asDoubleOrNull(),
175 + fontBold = json["font_bold"].asBooleanOrNull() ?: false,
176 + fontItalic = json["font_italic"].asBooleanOrNull() ?: false,
177 + labelPosition = json["label_position"].asStringOrNull() ?: "center",
178 + labelSize = json["label_size"].asStringOrNull() ?: "md",
179 + labelColor = json["label_color"].asStringOrNull(),
180 + labelFontFamily = json["label_font_family"].asStringOrNull(),
181 + labelBold = json["label_bold"].asBooleanOrNull() ?: false,
182 + labelItalic = json["label_italic"].asBooleanOrNull() ?: false,
183 + )
184 + }
185 + }
186 +}
187 +
188 +data class Anchor(
189 + val element: String,
190 + val side: String = "auto",
191 + val x: Double? = null,
192 + val y: Double? = null,
193 +) {
194 + companion object {
195 + fun parse(json: JsonObject?): Anchor? {
196 + json ?: return null
197 + val element = json["element"].asStringOrNull() ?: return null
198 + return Anchor(
199 + element = element,
200 + side = json["anchor"].asStringOrNull() ?: "auto",
201 + x = json["x"].asDoubleOrNull(),
202 + y = json["y"].asDoubleOrNull(),
203 + )
204 + }
205 + }
206 +}
207 +
208 +/** Defaults here must match `Document.validate_connector_style/1`. */
209 +data class Connector(
210 + val id: String,
211 + val from: Anchor,
212 + val to: Anchor,
213 + val routing: String = "orthogonal",
214 + val dash: String = "solid",
215 + val markerStart: String = "none",
216 + val markerEnd: String = "arrow",
217 + val startGap: Double = 6.0,
218 + val endGap: Double = 6.0,
219 + val strokeWidth: Double = 2.0,
220 + val color: String = "base-content",
221 + val label: String = "",
222 +) {
223 + companion object {
224 + fun parse(id: String, json: JsonObject): Connector? {
225 + val from = Anchor.parse(json["from"] as? JsonObject) ?: return null
226 + val to = Anchor.parse(json["to"] as? JsonObject) ?: return null
227 +
228 + return Connector(
229 + id = id,
230 + from = from,
231 + to = to,
232 + routing = json["routing"].asStringOrNull() ?: "orthogonal",
233 + dash = json["dash"].asStringOrNull() ?: "solid",
234 + markerStart = json["marker_start"].asStringOrNull() ?: "none",
235 + markerEnd = json["marker_end"].asStringOrNull() ?: "arrow",
236 + startGap = json["start_gap"].asDoubleOrNull() ?: 6.0,
237 + endGap = json["end_gap"].asDoubleOrNull() ?: 6.0,
238 + strokeWidth = json["stroke_width"].asDoubleOrNull() ?: 2.0,
239 + color = json["color"].asStringOrNull() ?: "base-content",
240 + label = json["label"].asStringOrNull().orEmpty(),
241 + )
242 + }
243 + }
244 +}
245 +
246 +private fun JsonElement?.asStringOrNull(): String? =
247 + (this as? JsonPrimitive)?.takeIf { it.isString }?.content
248 +
249 +private fun JsonElement?.asDoubleOrNull(): Double? =
250 + (this as? JsonPrimitive)?.takeIf { !it.isString }?.doubleOrNull
251 +
252 +private fun JsonElement?.asBooleanOrNull(): Boolean? =
253 + (this as? JsonPrimitive)?.booleanOrNull
254 +
255 +internal fun JsonElement.objectOrNull(): JsonObject? = runCatching { jsonObject }.getOrNull()
256 +
257 +internal fun JsonElement.primitiveOrNull(): JsonPrimitive? =
258 + runCatching { jsonPrimitive }.getOrNull()
modified android/app/src/main/java/org/neiam/diogramos/app/canvas/CanvasListScreen.kt
+68 −24
@@ -1,6 +1,8 @@
1 1 package org.neiam.diogramos.app.canvas
2 2
3 +import androidx.compose.foundation.clickable
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.fillMaxSize
6 8 import androidx.compose.foundation.layout.fillMaxWidth
@@ -21,40 +23,50 @@ import androidx.compose.runtime.getValue
21 23 import androidx.compose.runtime.mutableStateOf
22 24 import androidx.compose.runtime.remember
23 25 import androidx.compose.runtime.setValue
26 +import androidx.compose.ui.Alignment
24 27 import androidx.compose.ui.Modifier
25 28 import androidx.compose.ui.unit.dp
26 29 import org.neiam.diogramos.app.data.CanvasSummary
27 30 import org.neiam.diogramos.app.data.DiogramosClient
31 +import org.neiam.diogramos.app.data.Folder
28 32 import org.neiam.diogramos.app.data.TokenStore
29 33 import org.neiam.diogramos.app.data.describeApiFailure
30 34 import org.neiam.diogramos.app.ui.AppBarTitle
31 35
32 36 /**
33 − * Where a paired device lands. Phase 1b only proves the credentials work
34 − * end to end — the list is read-only and tapping a canvas does nothing
35 − * yet. Phase 2 turns each row into the rendered canvas.
37 + * Canvases grouped by folder, newest first inside each group — the order
38 + * the API returns them in, which is `updated_at` descending.
36 39 */
37 40 @OptIn(ExperimentalMaterial3Api::class)
38 41 @Composable
39 42 fun CanvasListScreen(
40 43 tokens: TokenStore,
44 + onOpen: (String) -> Unit,
41 45 onSignOut: () -> Unit,
42 46 ) {
43 47 var canvases by remember { mutableStateOf<List<CanvasSummary>>(emptyList()) }
48 + var folders by remember { mutableStateOf<List<Folder>>(emptyList()) }
44 49 var error by remember { mutableStateOf<String?>(null) }
45 50 var loading by remember { mutableStateOf(true) }
46 51
47 − val creds = remember { tokens.load() }
52 + val credentials = remember { tokens.load() }
48 53
49 − LaunchedEffect(creds) {
50 − val credentials = creds ?: run {
54 + LaunchedEffect(credentials) {
55 + val creds = credentials ?: run {
51 56 onSignOut()
52 57 return@LaunchedEffect
53 58 }
54 59
55 − runCatching { DiogramosClient.authenticated(credentials).canvases() }
60 + val api = DiogramosClient.authenticated(creds)
61 +
62 + runCatching {
63 + // Folders are only labels for the grouping, so a failure there
64 + // must not cost the user their canvas list.
65 + folders = runCatching { api.folders().data }.getOrDefault(emptyList())
66 + api.canvases().data
67 + }
56 68 .onSuccess {
57 − canvases = it.data
69 + canvases = it
58 70 error = null
59 71 }
60 72 .onFailure { error = describeApiFailure(it) }
@@ -62,6 +74,14 @@ fun CanvasListScreen(
62 74 loading = false
63 75 }
64 76
77 + val folderNames = remember(folders) { folders.associate { it.id to it.name } }
78 + val grouped = remember(canvases, folderNames) {
79 + canvases.groupBy { it.folderId }
80 + .toList()
81 + // Loose canvases first, then folders alphabetically.
82 + .sortedWith(compareBy({ it.first != null }, { folderNames[it.first] ?: "" }))
83 + }
84 +
65 85 Scaffold(
66 86 topBar = {
67 87 TopAppBar(
@@ -77,38 +97,43 @@ fun CanvasListScreen(
77 97 )
78 98 },
79 99 ) { padding ->
80 − Column(
100 + Box(
81 101 modifier = Modifier
82 102 .fillMaxSize()
83 − .padding(padding)
84 − .padding(16.dp),
85 − verticalArrangement = Arrangement.spacedBy(12.dp),
103 + .padding(padding),
86 104 ) {
87 105 when {
88 − loading -> CircularProgressIndicator()
106 + loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
89 107
90 108 error != null -> Text(
91 109 error!!,
110 + modifier = Modifier.align(Alignment.Center).padding(24.dp),
92 111 color = MaterialTheme.colorScheme.error,
93 112 style = MaterialTheme.typography.bodyMedium,
94 113 )
95 114
96 115 canvases.isEmpty() -> Text(
97 116 "No canvases yet. Make one in the web UI and it shows up here.",
117 + modifier = Modifier.align(Alignment.Center).padding(24.dp),
98 118 style = MaterialTheme.typography.bodyMedium,
99 119 )
100 120
101 − else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
102 − items(canvases, key = { it.id }) { canvas ->
103 − Card(modifier = Modifier.fillMaxWidth()) {
104 − Column(modifier = Modifier.padding(12.dp)) {
105 − Text(canvas.title, style = MaterialTheme.typography.titleMedium)
106 − Text(
107 − "${canvas.slug} · ${canvas.theme} · ${canvas.role ?: "?"}",
108 − style = MaterialTheme.typography.bodySmall,
109 − color = MaterialTheme.colorScheme.onSurfaceVariant,
110 − )
111 − }
121 + else -> LazyColumn(
122 + modifier = Modifier.fillMaxSize().padding(16.dp),
123 + verticalArrangement = Arrangement.spacedBy(8.dp),
124 + ) {
125 + grouped.forEach { (folderId, inFolder) ->
126 + item(key = "folder-${folderId ?: 0}") {
127 + Text(
128 + folderNames[folderId] ?: "Loose canvases",
129 + style = MaterialTheme.typography.titleSmall,
130 + color = MaterialTheme.colorScheme.onSurfaceVariant,
131 + modifier = Modifier.padding(top = 8.dp),
132 + )
133 + }
134 +
135 + items(inFolder, key = { it.id }) { canvas ->
136 + CanvasRow(canvas = canvas, onClick = { onOpen(canvas.slug) })
112 137 }
113 138 }
114 139 }
@@ -116,3 +141,22 @@ fun CanvasListScreen(
116 141 }
117 142 }
118 143 }
144 +
145 +@Composable
146 +private fun CanvasRow(canvas: CanvasSummary, onClick: () -> Unit) {
147 + Card(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)) {
148 + Column(modifier = Modifier.padding(12.dp)) {
149 + Text(canvas.title, style = MaterialTheme.typography.titleMedium)
150 + Text(
151 + buildString {
152 + append(canvas.slug)
153 + append(" · ")
154 + append(canvas.theme)
155 + if (!canvas.canWrite) append(" · read-only")
156 + },
157 + style = MaterialTheme.typography.bodySmall,
158 + color = MaterialTheme.colorScheme.onSurfaceVariant,
159 + )
160 + }
161 + }
162 +}
added android/app/src/main/java/org/neiam/diogramos/app/canvas/CanvasScreen.kt
+157 −0
@@ -0,0 +1,157 @@
1 +package org.neiam.diogramos.app.canvas
2 +
3 +import androidx.compose.foundation.Canvas
4 +import androidx.compose.foundation.gestures.detectTapGestures
5 +import androidx.compose.foundation.gestures.detectTransformGestures
6 +import androidx.compose.foundation.layout.Box
7 +import androidx.compose.foundation.layout.Column
8 +import androidx.compose.foundation.layout.fillMaxSize
9 +import androidx.compose.foundation.layout.padding
10 +import androidx.compose.material.icons.Icons
11 +import androidx.compose.material.icons.automirrored.filled.ArrowBack
12 +import androidx.compose.material.icons.filled.CenterFocusStrong
13 +import androidx.compose.material3.CircularProgressIndicator
14 +import androidx.compose.material3.ExperimentalMaterial3Api
15 +import androidx.compose.material3.FloatingActionButton
16 +import androidx.compose.material3.Icon
17 +import androidx.compose.material3.IconButton
18 +import androidx.compose.material3.MaterialTheme
19 +import androidx.compose.material3.Scaffold
20 +import androidx.compose.material3.Text
21 +import androidx.compose.runtime.Composable
22 +import androidx.compose.runtime.LaunchedEffect
23 +import androidx.compose.runtime.getValue
24 +import androidx.compose.runtime.mutableStateOf
25 +import androidx.compose.runtime.remember
26 +import androidx.compose.runtime.saveable.rememberSaveable
27 +import androidx.compose.runtime.setValue
28 +import androidx.compose.ui.Alignment
29 +import androidx.compose.ui.Modifier
30 +import androidx.compose.ui.geometry.Size
31 +import androidx.compose.ui.input.pointer.pointerInput
32 +import androidx.compose.ui.text.rememberTextMeasurer
33 +import androidx.compose.ui.unit.dp
34 +import org.neiam.diogramos.app.data.Canvas as CanvasMeta
35 +import org.neiam.diogramos.app.data.DiogramosClient
36 +import org.neiam.diogramos.app.data.TokenStore
37 +import org.neiam.diogramos.app.data.describeApiFailure
38 +import org.neiam.diogramos.app.ui.AppBarTitle
39 +import org.neiam.diogramos.app.ui.theme.appThemeByKey
40 +
41 +/**
42 + * The canvas itself: pan, zoom, and a faithful render of the document.
43 + *
44 + * Read-only for now — phase 4 adds selection and editing. Phase 3 adds
45 + * the channel, at which point the document stops being a one-shot fetch.
46 + */
47 +@OptIn(ExperimentalMaterial3Api::class)
48 +@Composable
49 +fun CanvasScreen(
50 + slug: String,
51 + tokens: TokenStore,
52 + onBack: () -> Unit,
53 +) {
54 + var meta by remember { mutableStateOf<CanvasMeta?>(null) }
55 + var document by remember { mutableStateOf(CanvasDocument.EMPTY) }
56 + var error by remember { mutableStateOf<String?>(null) }
57 + var loading by remember { mutableStateOf(true) }
58 + var viewSize by remember { mutableStateOf(Size.Zero) }
59 +
60 + val viewport = rememberSaveable(saver = Viewport.Saver) { Viewport() }
61 + val credentials = remember { tokens.load() }
62 +
63 + LaunchedEffect(slug, credentials) {
64 + val creds = credentials ?: return@LaunchedEffect
65 +
66 + runCatching { DiogramosClient.authenticated(creds).canvas(slug) }
67 + .onSuccess {
68 + meta = it.data
69 + document = CanvasDocument.parse(it.data.document)
70 + error = null
71 + }
72 + .onFailure { error = describeApiFailure(it) }
73 +
74 + loading = false
75 + }
76 +
77 + // The canvas's own theme, not the app's: a diagram authored in
78 + // `forest` renders in forest colors wherever it is opened.
79 + val canvasTheme = appThemeByKey(meta?.theme)
80 + val textMeasurer = rememberTextMeasurer()
81 + val renderer = remember(canvasTheme) { Renderer(canvasTheme, textMeasurer) }
82 +
83 + // Frame the content once it is known, and only once, so a later
84 + // recomposition doesn't yank the view back from where it was panned.
85 + LaunchedEffect(document, viewSize) {
86 + if (!viewport.hasFitted && viewSize != Size.Zero && document.elements.isNotEmpty()) {
87 + viewport.fit(document.bounds, viewSize)
88 + }
89 + }
90 +
91 + Scaffold(
92 + topBar = {
93 + androidx.compose.material3.TopAppBar(
94 + title = { AppBarTitle(meta?.title ?: slug) },
95 + navigationIcon = {
96 + IconButton(onClick = onBack) {
97 + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
98 + }
99 + },
100 + )
101 + },
102 + floatingActionButton = {
103 + if (document.elements.isNotEmpty()) {
104 + FloatingActionButton(onClick = { viewport.fit(document.bounds, viewSize) }) {
105 + Icon(Icons.Default.CenterFocusStrong, contentDescription = "Fit to content")
106 + }
107 + }
108 + },
109 + ) { padding ->
110 + Box(
111 + modifier = Modifier
112 + .fillMaxSize()
113 + .padding(padding),
114 + ) {
115 + when {
116 + loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
117 +
118 + error != null -> Column(Modifier.align(Alignment.Center).padding(24.dp)) {
119 + Text(
120 + error!!,
121 + color = MaterialTheme.colorScheme.error,
122 + style = MaterialTheme.typography.bodyMedium,
123 + )
124 + }
125 +
126 + else -> Canvas(
127 + modifier = Modifier
128 + .fillMaxSize()
129 + .pointerInput(Unit) {
130 + detectTransformGestures { centroid, panChange, zoomChange, _ ->
131 + viewport.pan(panChange.x, panChange.y)
132 + if (zoomChange != 1f) viewport.zoomAround(centroid, zoomChange)
133 + }
134 + }
135 + .pointerInput(document) {
136 + detectTapGestures(
137 + onDoubleTap = { viewport.fit(document.bounds, viewSize) },
138 + )
139 + },
140 + ) {
141 + viewSize = size
142 + drawRect(canvasTheme.base100, size = size)
143 + renderer.draw(this, document, viewport)
144 + }
145 + }
146 +
147 + if (!loading && error == null && document.elements.isEmpty()) {
148 + Text(
149 + "This canvas is empty.",
150 + modifier = Modifier.align(Alignment.Center),
151 + style = MaterialTheme.typography.bodyMedium,
152 + color = MaterialTheme.colorScheme.onSurfaceVariant,
153 + )
154 + }
155 + }
156 + }
157 +}
added android/app/src/main/java/org/neiam/diogramos/app/canvas/ConnectorGeometry.kt
+213 −0

Click to load diff…

added android/app/src/main/java/org/neiam/diogramos/app/canvas/Renderer.kt
+337 −0

Click to load diff…

added android/app/src/main/java/org/neiam/diogramos/app/canvas/Viewport.kt
+96 −0

Click to load diff…

added android/app/src/test/java/org/neiam/diogramos/app/CanvasDocumentTest.kt
+166 −0

Click to load diff…

added android/app/src/test/java/org/neiam/diogramos/app/ConnectorGeometryTest.kt
+141 −0

Click to load diff…

added android/app/src/test/java/org/neiam/diogramos/app/ViewportTest.kt
+86 −0

Click to load diff…

added android/app/src/test/resources/connector_geometry.json
+10922 −0

Click to load diff…

modified docs/android.md
+43 −5

Click to load diff…

added lib/mix/tasks/diogramos.android_fixtures.ex
+186 −0

Click to load diff…

Parents: b9a6d14