neiam
/diogramos
diogramos
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Android client phase 2: canvas list and read-only render
99f6896 · Gabriel Morell · 2026-09-14 22:30
Message
{commit_body(@commit)}
Files changed
modified
android/README.md
+31
−6
| 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 | 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 | 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 | 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 | 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 | 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 | 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
| 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 | 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 | 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 | 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 | 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 | 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
| 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