diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/data/GraphDataModel.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/data/GraphDataModel.kt index cd5bb6c..dc93d46 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/data/GraphDataModel.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/data/GraphDataModel.kt @@ -395,7 +395,11 @@ sealed class Port( ) { abstract val side: PortSide - class Output(nodeId: String, id: String = "output", label: DisplayText? = null) : + companion object { + const val OUTPUT_PORT_ID = "output" + } + + class Output(nodeId: String, id: String = OUTPUT_PORT_ID, label: DisplayText? = null) : Port(nodeId, id, label, isAddPort = false) { override val side = PortSide.OUTPUT } diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GeometryUtils.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GeometryUtils.kt new file mode 100644 index 0000000..43019aa --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GeometryUtils.kt @@ -0,0 +1,72 @@ +/* + * * Copyright 2026 Google LLC. All rights reserved. + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + */ +package com.example.cahier.developer.brushgraph.ui + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Path +import kotlin.math.max +import kotlin.math.min +import kotlin.math.abs + +internal const val SPLINE_HIT_SEGMENTS = 50 + +/** Calculates the shortest distance from point [p] to the line segment from [a] to [b] using vector projection. */ +internal fun distanceToSegment(p: Offset, a: Offset, b: Offset): Float { + val l2 = (b - a).getDistanceSquared() + if (l2 == 0f) return (p - a).getDistance() + var t = ((p.x - a.x) * (b.x - a.x) + (p.y - a.y) * (b.y - a.y)) / l2 + t = max(0f, min(1f, t)) + return (p - (a + (b - a) * t)).getDistance() +} + +/** Creates a cubic Bezier curve [Path] between [start] and [end] with horizontal control points for an S-curve. */ +internal fun createSplinePath(start: Offset, end: Offset): Path { + val horizontalOffset = maxOf(50f, abs(end.x - start.x) / 2f).coerceAtMost(200f) + return Path().apply { + moveTo(start.x, start.y) + cubicTo(start.x + horizontalOffset, start.y, end.x - horizontalOffset, end.y, end.x, end.y) + } +} + +/** Approximates the shortest distance from point [p] to the spline by dividing it into [SPLINE_HIT_SEGMENTS] linear segments. */ +internal fun distanceToSpline(p: Offset, start: Offset, end: Offset): Float { + val horizontalOffset = maxOf(50f, abs(end.x - start.x) / 2f).coerceAtMost(200f) + val cp1 = Offset(start.x + horizontalOffset, start.y) + val cp2 = Offset(end.x - horizontalOffset, end.y) + + var minDistance = Float.MAX_VALUE + var prevPoint = start + for (i in 1..SPLINE_HIT_SEGMENTS) { + val t = i.toFloat() / SPLINE_HIT_SEGMENTS + val currentPoint = cubicBezier(t, start, cp1, cp2, end) + minDistance = min(minDistance, distanceToSegment(p, prevPoint, currentPoint)) + prevPoint = currentPoint + } + return minDistance +} + +/** Evaluates a point on a cubic Bezier curve at time [t] (0.0 to 1.0) using the standard polynomial formula. */ +internal fun cubicBezier(t: Float, p0: Offset, p1: Offset, p2: Offset, p3: Offset): Offset { + val u = 1 - t + val tt = t * t + val uu = u * u + val uuu = uu * u + val ttt = tt * t + + val x = uuu * p0.x + 3 * uu * t * p1.x + 3 * u * tt * p2.x + ttt * p3.x + val y = uuu * p0.y + 3 * uu * t * p1.y + 3 * u * tt * p2.y + ttt * p3.y + return Offset(x, y) +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphCameraController.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphCameraController.kt new file mode 100644 index 0000000..500b30b --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphCameraController.kt @@ -0,0 +1,138 @@ +/* + * * Copyright 2026 Google LLC. All rights reserved. + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + */ +@file:OptIn(androidx.ink.brush.ExperimentalInkCustomBrushApi::class) + +package com.example.cahier.developer.brushgraph.ui + +import android.content.Context +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.unit.Dp +import com.example.cahier.developer.brushgraph.data.BrushGraph +import com.example.cahier.developer.brushgraph.data.GraphNode +import com.example.cahier.developer.brushgraph.data.TutorialAnchor +import com.example.cahier.developer.brushgraph.data.TutorialStep +import com.example.cahier.developer.brushgraph.ui.node.NodeRegistry + +private const val TUTORIAL_TARGET_Y = 280f + +@Composable +fun GraphCameraController( + offset: Offset, + tutorialStep: TutorialStep?, + focusTrigger: Int, + graph: BrushGraph, + zoom: Float, + isPreviewExpanded: Boolean, + selectedNodeId: String?, + updateOffset: (Offset) -> Unit, + viewportSize: Size, + context: Context, + isLandscape: Boolean, + maxWidthDp: Dp, + nodeRegistry: NodeRegistry +) { + val animatableOffset = remember { Animatable(offset, Offset.VectorConverter) } + + // Auto-pan to node in tutorial + LaunchedEffect(tutorialStep) { + val step = tutorialStep + if (step != null && step.anchor == TutorialAnchor.NODE_CANVAS) { + val node = step.getTargetNode(graph) + if (node != null) { + val density = context.resources.displayMetrics.density + val targetY = TUTORIAL_TARGET_Y * density + val targetX = maxWidthDp.value * density / 2f + + val newOffset = calculateFocusOffset( + node = node, + position = nodeRegistry.getNodePosition(node.id) ?: Offset.Zero, + zoom = zoom, + targetScreenPos = Offset(targetX, targetY) + ) + + animatableOffset.snapTo(offset) + animatableOffset.animateTo(newOffset, animationSpec = tween(500)) { + updateOffset(this.value) + } + } + } + } + + // Listen for ViewModel events (e.g. center on node) + LaunchedEffect(focusTrigger) { + if (focusTrigger > 0) { + selectedNodeId?.let { nodeId -> + val node = graph.nodes.find { it.id == nodeId } + if (node != null) { + val density = context.resources.displayMetrics.density + val newOffset = calculateFocusOffset( + node = node, + position = nodeRegistry.getNodePosition(node.id) ?: Offset.Zero, + zoom = zoom, + viewportSize = viewportSize, + density = density, + isLandscape = isLandscape, + isPreviewExpanded = isPreviewExpanded + ) + animatableOffset.snapTo(offset) + animatableOffset.animateTo(newOffset, animationSpec = tween(500)) { + updateOffset(this.value) + } + } + } + } + } +} + +private fun calculateFocusOffset( + node: GraphNode, + position: Offset, + zoom: Float, + viewportSize: Size = Size.Zero, + density: Float = 1f, + isLandscape: Boolean = false, + isPreviewExpanded: Boolean = false, + targetScreenPos: Offset? = null +): Offset { + val nodeCenterX = position.x + node.data.width() / 2f + val nodeCenterY = position.y + node.data.height() / 2f + + val targetPos = if (targetScreenPos != null) { + Pair(targetScreenPos.x, targetScreenPos.y) + } else { + val previewHeightPx = (if (isPreviewExpanded) PREVIEW_HEIGHT_EXPANDED else PREVIEW_HEIGHT_COLLAPSED) * density + val safeSize = if (isLandscape) { + val inspectorWidthPx = INSPECTOR_WIDTH_LANDSCAPE * density + Pair(viewportSize.width - inspectorWidthPx, viewportSize.height - previewHeightPx) + } else { + val inspectorHeightPx = INSPECTOR_HEIGHT_PORTRAIT * density + Pair(viewportSize.width, viewportSize.height - maxOf(inspectorHeightPx, previewHeightPx)) + } + Pair(safeSize.first / 2f, safeSize.second / 2f) + } + + val targetX = targetPos.first + val targetY = targetPos.second + + return Offset(targetX - nodeCenterX * zoom, targetY - nodeCenterY * zoom) +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphCanvas.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphCanvas.kt new file mode 100644 index 0000000..bc99442 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphCanvas.kt @@ -0,0 +1,578 @@ +/* + * * Copyright 2026 Google LLC. All rights reserved. + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + */ +@file:OptIn(androidx.ink.brush.ExperimentalInkCustomBrushApi::class) + +package com.example.cahier.developer.brushgraph.ui + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.gestures.drag +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.drawscope.Stroke as DrawStroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import androidx.ink.brush.Brush +import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer +import androidx.ink.brush.TextureBitmapStore +import com.example.cahier.R +import com.example.cahier.developer.brushgraph.ui.node.NodeRegistry +import com.example.cahier.developer.brushgraph.ui.node.NodeWidget +import com.example.cahier.developer.brushgraph.data.BrushGraph +import com.example.cahier.developer.brushgraph.data.GraphEdge +import com.example.cahier.developer.brushgraph.data.GraphNode +import com.example.cahier.developer.brushgraph.data.NodeData +import com.example.cahier.developer.brushgraph.data.Port +import com.example.cahier.developer.brushgraph.data.PortSide +import kotlin.math.roundToInt + +/** + * A composable that renders an infinite canvas for the node graph. Handles panning, zooming, and + * node interaction. + */ +@Composable +fun GraphCanvas( + graph: BrushGraph, + zoom: Float, + offset: Offset, + onZoomChange: (Float) -> Unit, + onOffsetChange: (Offset) -> Unit, + onNodeClick: (String, Offset) -> Unit, + onNodeDelete: (String) -> Unit, + onAddEdge: (String, String, String) -> Unit, + onEdgeClick: (GraphEdge) -> Unit, + onEdgeDelete: (GraphEdge) -> Unit, + nodeRegistry: NodeRegistry, + onChooseColor: (Color, (Color) -> Unit) -> Unit, + textureStore: TextureBitmapStore, + allTextureIds: Set, + onLoadTexture: () -> Unit, + strokeRenderer: CanvasStrokeRenderer, + textFieldsLocked: Boolean, + brush: Brush, + modifier: Modifier = Modifier, + onNodeMoveFinished: () -> Unit = {}, + onNodeLongPress: (String) -> Unit = {}, + onEdgeDetach: (GraphEdge) -> Unit = {}, + onFinalizeEdgeEdit: (GraphEdge, String, String, String) -> Unit = { _, _, _, _ -> }, + onCanvasClick: () -> Unit = {}, + onPortClick: (String, Port) -> Unit = { _, _ -> }, + onReorderPorts: (String, Int, Int) -> Unit = { _, _, _ -> }, + activeEdgeSourceId: String? = null, + selectedNodeId: String? = null, + selectedEdge: GraphEdge? = null, + detachedEdge: GraphEdge? = null, + onNodeDataUpdate: (String, NodeData) -> Unit = { _, _ -> }, + bottomPadding: Dp = 16.dp, + isSelectionMode: Boolean = false, + selectedNodeIds: Set = emptySet(), + onSelectAll: () -> Unit = {}, + onDuplicateSelected: () -> Unit = {}, + onDeleteSelected: () -> Unit = {}, + onDoneSelection: () -> Unit = {}, +) { + var pointerPos by remember { mutableStateOf(null) } + var draggingNodeId by remember { mutableStateOf(null) } + var draggingPointerPos by remember { mutableStateOf(null) } // In parent Box space + var activeSourcePort by remember { mutableStateOf(null) } + var canvasCoordinates by remember { mutableStateOf(null) } + + val currentZoom by androidx.compose.runtime.rememberUpdatedState(zoom) + val currentOffset by androidx.compose.runtime.rememberUpdatedState(offset) + val currentOnZoomChange by androidx.compose.runtime.rememberUpdatedState(onZoomChange) + val currentOnOffsetChange by androidx.compose.runtime.rememberUpdatedState(onOffsetChange) + val currentOnCanvasClick by androidx.compose.runtime.rememberUpdatedState(onCanvasClick) + + val currentGraph by androidx.compose.runtime.rememberUpdatedState(graph) + val currentOnEdgeClick by androidx.compose.runtime.rememberUpdatedState(onEdgeClick) + + val density = LocalDensity.current + val trashCenterPaddingPx = with(density) { (bottomPadding + 32.dp).toPx() } + + BoxWithConstraints(modifier = modifier.fillMaxSize()) { + val parentWidth = constraints.maxWidth.toFloat() + val parentHeight = constraints.maxHeight.toFloat() + + Box( + modifier = + Modifier.fillMaxSize() + .pointerInput(Unit) { + detectTransformGestures { centroid, pan, gestureZoom, _ -> + val newZoom = currentZoom * gestureZoom + currentOnZoomChange(newZoom) + // Ensure we zoom relative to the centroid. + val newOffset = (currentOffset - centroid) * gestureZoom + centroid + pan + currentOnOffsetChange(newOffset) + } + } + .pointerInput(Unit) { + detectTapGestures( + onTap = { tapOffset -> + val graphTap = (tapOffset - currentOffset) / currentZoom + currentGraph.edges + .find { edge -> + val fromNode = currentGraph.nodes.find { it.id == edge.fromNodeId } + val toNode = currentGraph.nodes.find { it.id == edge.toNodeId } + + val start = if (fromNode != null) { + nodeRegistry.getPortPosition(edge.fromNodeId, Port.OUTPUT_PORT_ID, currentGraph) + } else Offset.Zero + val end = if (toNode != null) { + nodeRegistry.getPortPosition(edge.toNodeId, edge.toPortId, currentGraph) + } else Offset.Zero + val threshold = 24f / currentZoom + val distance = distanceToSpline(graphTap, start, end) + distance < threshold + } + .let { edge -> + if (edge != null) { + currentOnEdgeClick(edge) + } else { + currentOnCanvasClick() + } + } + } + ) + } + ) { + Box( + modifier = + Modifier.fillMaxSize() + .graphicsLayer( + scaleX = zoom, + scaleY = zoom, + translationX = offset.x, + translationY = offset.y, + transformOrigin = TransformOrigin(0f, 0f), + ) + ) { + Box(modifier = Modifier.fillMaxSize().onGloballyPositioned { canvasCoordinates = it }) { + val outlineColor = MaterialTheme.colorScheme.outline + val activeEdgeColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) + val selectedEdgeColor = MaterialTheme.colorScheme.primary + EdgeRenderer( + graph = graph, + detachedEdge = detachedEdge, + selectedEdge = selectedEdge, + activeSourcePort = activeSourcePort, + pointerPos = pointerPos, + nodeRegistry = nodeRegistry, + selectedEdgeColor = selectedEdgeColor, + outlineColor = outlineColor, + activeEdgeColor = activeEdgeColor + ) + + for (node in graph.nodes) { + key(node.id) { + NodeWidget( + node = node, + position = nodeRegistry.getNodePosition(node.id) ?: Offset.Zero, + graph = graph, + isActiveSource = node.id == activeSourcePort?.nodeId, + isSelected = node.id == selectedNodeId, + zoom = zoom, + onMove = { delta -> + if (isSelectionMode && selectedNodeIds.contains(node.id)) { + selectedNodeIds.forEach { selId -> + val currentPos = nodeRegistry.getNodePosition(selId) ?: Offset.Zero + nodeRegistry.updateNodePosition(selId, currentPos + delta) + } + } else { + val currentPos = nodeRegistry.getNodePosition(node.id) ?: Offset.Zero + nodeRegistry.updateNodePosition(node.id, currentPos + delta) + } + }, + onClick = { onNodeClick(node.id, nodeRegistry.getNodePosition(node.id) ?: Offset.Zero) }, + onUpdate = { onNodeDataUpdate(node.id, it) }, + onPortClick = onPortClick, + onReorderPorts = onReorderPorts, + onDragStart = { draggingNodeId = node.id }, + isSelectionMode = isSelectionMode, + isInSelectedSet = selectedNodeIds.contains(node.id), + onLongPress = { onNodeLongPress(node.id) }, + onDrag = { change -> + val nodePos = nodeRegistry.getNodePosition(node.id) ?: Offset.Zero + val nodePosInParent = Offset(nodePos.x * zoom, nodePos.y * zoom) + offset + draggingPointerPos = nodePosInParent + change.position * zoom + }, + onDragEnd = { + draggingPointerPos?.let { pos -> + val trashCenter = Offset(parentWidth / 2f, parentHeight - trashCenterPaddingPx) + if ((pos - trashCenter).getDistance() < 100f) { + onNodeDelete(node.id) + } + } + draggingNodeId = null + draggingPointerPos = null + onNodeMoveFinished() + }, + onPortDrag = { side, portId, isStart -> + if (!isSelectionMode) { + if (isStart) { + if (side == PortSide.OUTPUT) { + activeSourcePort = Port.Output(node.id, portId) + } else if (side == PortSide.INPUT) { + val edge = graph.edges.find { it.toNodeId == node.id && it.toPortId == portId && !it.isDisabled } + + if (edge != null) { + activeSourcePort = Port.Output(edge.fromNodeId) + onEdgeDetach(edge) + } + } + } + } + }, + onPortDragUpdate = { pos -> + activeSourcePort?.let { sourcePort -> + val fromNodeId = sourcePort.nodeId + val fromNode = graph.nodes.find { it.id == fromNodeId } + if (fromNode != null) { + val snappedPort = nodeRegistry.findNearestPort(pos, fromNodeId, graph) + if (snappedPort != null) { + pointerPos = + nodeRegistry.getPortPosition( + snappedPort.nodeId, + snappedPort.id, + graph + ) + } else { + pointerPos = pos + } + } else { + pointerPos = pos + } + } + }, + onPortDragEnd = { + + val sourcePort = activeSourcePort + if (sourcePort != null) { + pointerPos?.let { pos -> + val fromNodeId = sourcePort.nodeId + val fromNode = graph.nodes.find { it.id == fromNodeId } + if (fromNode != null) { + val target = nodeRegistry.findNearestPort(pos, fromNodeId, graph) + + if (target != null) { + val currentDetached = detachedEdge + if (currentDetached != null) { + onFinalizeEdgeEdit(currentDetached, fromNodeId, target.nodeId, target.id) + } else { + onAddEdge(fromNodeId, target.nodeId, target.id) + } + } else { + detachedEdge?.let { + onEdgeDelete(it) + } + } + } + } + } + activeSourcePort = null + pointerPos = null + }, + getPortPosition = { portId, fallback -> nodeRegistry.getPortPosition(node.id, portId, graph, fallback) }, + onPortPositioned = { portId, pos -> nodeRegistry.updatePort(node.id, portId, pos) }, + onClearNodeCache = { nodeRegistry.clearNode(node.id) }, + canvasCoordinates = canvasCoordinates, + onChooseColor = onChooseColor, + allTextureIds = allTextureIds, + onLoadTexture = onLoadTexture, + strokeRenderer = strokeRenderer, + textFieldsLocked = textFieldsLocked, + brush = brush, + ) + } + } + } + } + + TrashCanArea( + graph = graph, + draggingNodeId = draggingNodeId, + draggingPointerPos = draggingPointerPos, + parentWidth = parentWidth, + parentHeight = parentHeight, + trashCenterPaddingPx = trashCenterPaddingPx, + bottomPadding = bottomPadding, + onNodeDelete = onNodeDelete, + modifier = Modifier.align(Alignment.BottomCenter) + ) + + SelectionActionMenu( + isSelectionMode = isSelectionMode, + onSelectAll = onSelectAll, + onDuplicateSelected = onDuplicateSelected, + onDeleteSelected = onDeleteSelected, + onDoneSelection = onDoneSelection, + modifier = Modifier.align(Alignment.TopStart) + ) + } + } +} + +/** + * Custom drag gesture detector that allows for a zoom-adjusted touch slop. This ensures that + * dragging to create an edge starts reliably even when the canvas is significantly zoomed in. + */ +suspend fun androidx.compose.ui.input.pointer.PointerInputScope.detectPortDragGestures( + zoom: Float, + onDragStart: (Offset) -> Unit = {}, + onDragEnd: () -> Unit = {}, + onDragCancel: () -> Unit = {}, + onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit, +) { + val touchSlop = viewConfiguration.touchSlop / zoom + + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + var drag: PointerInputChange? = null + var dragStartedCalled = false + + // Wait for the pointer to move beyond the (zoom-adjusted) touch slop. + val pointerId = down.id + var totalMainPositionChange = Offset.Zero + while (true) { + val event = awaitPointerEvent() + val dragEvent = event.changes.firstOrNull { it.id == pointerId } ?: break + if (dragEvent.isConsumed) break + if (dragEvent.changedToUpIgnoreConsumed()) break + + val positionChange = dragEvent.positionChange() + totalMainPositionChange += positionChange + val distance = totalMainPositionChange.getDistance() + if (distance >= touchSlop) { + onDragStart(dragEvent.position) + dragStartedCalled = true + onDrag(dragEvent, totalMainPositionChange) + if (dragEvent.isConsumed) { + drag = dragEvent + } + break + } + if (event.changes.all { it.changedToUpIgnoreConsumed() }) break + } + + if (drag != null || totalMainPositionChange.getDistance() >= touchSlop) { + val dragSuccessful = + drag(pointerId) { + onDrag(it, it.positionChange()) + it.consume() + } + if (!dragSuccessful) { + if (dragStartedCalled) onDragCancel() + } else { + if (dragStartedCalled) onDragEnd() + } + } + } +} + +@Composable +fun EdgeRenderer( + graph: BrushGraph, + detachedEdge: GraphEdge?, + selectedEdge: GraphEdge?, + activeSourcePort: Port?, + pointerPos: Offset?, + nodeRegistry: NodeRegistry, + selectedEdgeColor: Color, + outlineColor: Color, + activeEdgeColor: Color, +) { + Canvas(modifier = Modifier.fillMaxSize()) { + for (edge in graph.edges) { + if (edge == detachedEdge) continue + val fromNode = graph.nodes.find { it.id == edge.fromNodeId } + val toNode = graph.nodes.find { it.id == edge.toNodeId } + + val start = if (fromNode != null) { + nodeRegistry.getPortPosition(edge.fromNodeId, Port.OUTPUT_PORT_ID, graph) + } else Offset.Zero + val end = if (toNode != null) { + nodeRegistry.getPortPosition(edge.toNodeId, edge.toPortId, graph) + } else Offset.Zero + val isSelected = edge == selectedEdge + drawPath( + path = createSplinePath(start, end), + color = if (isSelected) selectedEdgeColor else outlineColor, + style = DrawStroke(width = if (isSelected) 6f else 3f), + alpha = if (edge.isDisabled) 0.38f else 1f, + ) + } + + // Draw temporary edge + val sourcePort = activeSourcePort + if (sourcePort != null && pointerPos != null) { + graph.nodes + .find { it.id == sourcePort.nodeId } + ?.let { node -> + val start = + nodeRegistry.getPortPosition(sourcePort.nodeId, sourcePort.id, graph) + drawPath( + path = createSplinePath(start, pointerPos), + color = activeEdgeColor, + style = DrawStroke(width = 3f), + ) + } + } + } +} + +@Composable +fun SelectionActionMenu( + isSelectionMode: Boolean, + onSelectAll: () -> Unit, + onDuplicateSelected: () -> Unit, + onDeleteSelected: () -> Unit, + onDoneSelection: () -> Unit, + modifier: Modifier = Modifier, +) { + var showDeleteConfirmation by remember { mutableStateOf(false) } + + if (isSelectionMode) { + if (showDeleteConfirmation) { + androidx.compose.material3.AlertDialog( + onDismissRequest = { showDeleteConfirmation = false }, + title = { Text(stringResource(R.string.bg_delete_nodes)) }, + text = { Text(stringResource(R.string.bg_delete_nodes_confirmation)) }, + confirmButton = { + TextButton( + onClick = { + onDeleteSelected() + showDeleteConfirmation = false + } + ) { + Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteConfirmation = false }) { Text(stringResource(R.string.bg_cancel)) } + }, + ) + } + + Surface( + modifier = modifier + .padding(start = 16.dp, top = 80.dp) + .wrapContentSize(), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + tonalElevation = 4.dp + ) { + Row( + modifier = Modifier.padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Button(onClick = onSelectAll) { + Text(stringResource(R.string.bg_select_all)) + } + Button(onClick = onDuplicateSelected) { + Text(stringResource(R.string.bg_duplicate)) + } + Button(onClick = { showDeleteConfirmation = true }) { + Text(stringResource(R.string.delete)) + } + Button(onClick = onDoneSelection) { + Text(stringResource(R.string.done)) + } + } + } + } +} + +@Composable +fun TrashCanArea( + graph: BrushGraph, + draggingNodeId: String?, + draggingPointerPos: Offset?, + parentWidth: Float, + parentHeight: Float, + trashCenterPaddingPx: Float, + bottomPadding: Dp, + onNodeDelete: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val draggingNode = graph.nodes.find { it.id == draggingNodeId } + if (draggingNode != null && draggingNode.data !is NodeData.Family) { + Surface( + modifier = + modifier.padding(bottom = bottomPadding).graphicsLayer { + val isOver = + draggingPointerPos?.let { pos -> + val trashCenter = Offset(parentWidth / 2f, parentHeight - trashCenterPaddingPx) + (pos - trashCenter).getDistance() < 100f + } ?: false + scaleX = if (isOver) 1.2f else 1.0f + scaleY = if (isOver) 1.2f else 1.0f + }, + shape = CircleShape, + color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.8f), + tonalElevation = 8.dp, + ) { + Box(modifier = Modifier.size(64.dp), contentAlignment = Alignment.Center) { + Icon( + Icons.Default.Delete, + contentDescription = stringResource(R.string.bg_cd_delete), + tint = MaterialTheme.colorScheme.onErrorContainer, + ) + } + } + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphLayout.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphLayout.kt index e39f8b0..6a32758 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphLayout.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphLayout.kt @@ -20,7 +20,6 @@ import com.example.cahier.developer.brushgraph.data.BrushGraph import com.example.cahier.developer.brushgraph.data.GraphNode import com.example.cahier.developer.brushgraph.data.NodeData import com.example.cahier.developer.brushgraph.data.Port -import java.util.UUID const val NODE_WIDTH = 300f const val NODE_PADDING_VERTICAL = 8f @@ -180,6 +179,7 @@ object GraphLayout { nodeSubtreeMaxY: MutableMap ): Float { val node = graph.nodes.find { it.id == nodeId } ?: return desiredY + if (depth > 100) return desiredY // Prevent stack overflow on extremely deep graphs val data = node.data as? NodeData.Behavior ?: return desiredY if (assignedNodeIds.contains(nodeId)) { diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/Inspector.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/Inspector.kt new file mode 100644 index 0000000..d058d65 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/Inspector.kt @@ -0,0 +1,348 @@ +/* + * * Copyright 2026 Google LLC. All rights reserved. + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + */ +@file:OptIn(androidx.ink.brush.ExperimentalInkCustomBrushApi::class) + +package com.example.cahier.developer.brushgraph.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Help +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.* +import com.example.cahier.developer.brushgraph.ui.asString +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer +import com.example.cahier.developer.brushgraph.data.GraphEdge +import com.example.cahier.developer.brushgraph.data.GraphNode +import com.example.cahier.developer.brushgraph.data.NodeData +import com.example.cahier.developer.brushgraph.ui.fields.NodeFields +import com.example.cahier.developer.brushgraph.ui.TooltipDialog +import com.example.cahier.developer.brushgraph.ui.getTooltip +import androidx.compose.ui.res.stringResource +import com.example.cahier.R +import com.example.cahier.developer.brushgraph.data.DisplayText + +/** Shows connection details between two nodes and allows deletion. */ +@Composable +fun EdgeInspector( + edge: GraphEdge, + fromNode: GraphNode, + toNode: GraphNode, + onNodeFocus: (String) -> Unit, + onDisableChange: (Boolean) -> Unit, + onDelete: () -> Unit, + onAddNodeBetween: () -> Unit, + modifier: Modifier = Modifier, + inputLabel: DisplayText? = null, +) { + var showDeleteConfirmation by remember { mutableStateOf(false) } + + if (showDeleteConfirmation) { + AlertDialog( + onDismissRequest = { showDeleteConfirmation = false }, + title = { Text(stringResource(R.string.bg_delete_edge)) }, + text = { Text(stringResource(R.string.bg_delete_edge_confirmation)) }, + confirmButton = { + TextButton( + onClick = { + onDelete() + showDeleteConfirmation = false + } + ) { + Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteConfirmation = false }) { Text(stringResource(R.string.bg_cancel)) } + }, + ) + } + + Column(modifier = modifier.fillMaxSize().padding(16.dp)) { + // From Node Section + EdgeNodeInfo(label = DisplayText.Resource(R.string.bg_label_from), node = fromNode, onClick = { onNodeFocus(fromNode.id) }) + if (fromNode.data is NodeData.Behavior && toNode.data is NodeData.Behavior) { + Spacer(Modifier.height(8.dp)) + Button( + onClick = { onAddNodeBetween() }, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) { + Text(stringResource(R.string.bg_add_node_between)) + } + Spacer(Modifier.height(8.dp)) + } else { + Spacer(Modifier.height(16.dp)) + } + + // To Node Section + EdgeNodeInfo( + label = DisplayText.Resource(R.string.bg_label_to), + node = toNode, + inputLabel = inputLabel, + onClick = { onNodeFocus(toNode.id) }, + ) + + Spacer(modifier = Modifier.weight(1f)) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp) + ) { + Checkbox( + checked = edge.isDisabled, + onCheckedChange = { checked -> + onDisableChange(checked) + } + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.bg_disable_edge)) + } + Spacer(Modifier.height(8.dp)) + + Button( + onClick = { showDeleteConfirmation = true }, + modifier = Modifier.fillMaxWidth().height(48.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text(stringResource(R.string.delete)) + } + } +} + +@Composable +private fun EdgeNodeInfo( + label: DisplayText, + node: GraphNode, + inputLabel: DisplayText? = null, + onClick: () -> Unit, +) { + val title = node.data.title() + val subtitles = node.data.subtitles() + + Column(modifier = Modifier.fillMaxWidth().clickable { onClick() }.padding(8.dp)) { + Text( + text = label.asString(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + Text(text = stringResource(title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + for (subtitle in subtitles) { + val text = subtitle.asString() + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (inputLabel != null) { + val labelText = inputLabel.asString() + Text( + text = stringResource(R.string.bg_label_input_with_value, labelText), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** Renders the content of the node inspector. */ +@Composable +fun NodeInspector( + node: GraphNode, + onUpdate: (NodeData) -> Unit, + onDisableChange: (Boolean) -> Unit, + onChooseColor: (Color, (Color) -> Unit) -> Unit, + allTextureIds: Set, + onLoadTexture: () -> Unit, + strokeRenderer: CanvasStrokeRenderer, + textFieldsLocked: Boolean, + onDelete: () -> Unit, + modifier: Modifier = Modifier, + onFieldEditComplete: () -> Unit = {}, + onDropdownEditComplete: () -> Unit = {}, +) { + var showDeleteConfirmation by remember { mutableStateOf(false) } + + if (showDeleteConfirmation) { + AlertDialog( + onDismissRequest = { showDeleteConfirmation = false }, + title = { Text(stringResource(R.string.bg_delete_node)) }, + text = { Text(stringResource(R.string.bg_delete_node_confirmation)) }, + confirmButton = { + TextButton( + onClick = { + onDelete() + showDeleteConfirmation = false + } + ) { + Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteConfirmation = false }) { Text(stringResource(R.string.bg_cancel)) } + }, + ) + } + + Column(modifier = modifier.fillMaxSize().padding(16.dp)) { + Box(modifier = Modifier.weight(1f)) { + NodeFields( + node = node, + onUpdate = onUpdate, + onChooseColor = onChooseColor, + allTextureIds = allTextureIds, + onLoadTexture = onLoadTexture, + strokeRenderer = strokeRenderer, + textFieldsLocked = textFieldsLocked, + onFieldEditComplete = onFieldEditComplete, + onDropdownEditComplete = onDropdownEditComplete, + ) + } + + Spacer(Modifier.height(16.dp)) + + if (node.data !is NodeData.Family) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp) + ) { + Checkbox( + checked = node.isDisabled, + onCheckedChange = { checked -> + onDisableChange(checked) + } + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.bg_disable_node)) + } + Spacer(Modifier.height(8.dp)) + + Button( + onClick = { showDeleteConfirmation = true }, + modifier = Modifier.fillMaxWidth().height(48.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text(stringResource(R.string.delete)) + } + } + } +} + +@Composable +fun AdaptiveInspectorPane( + isLandscape: Boolean, + visible: Boolean, + title: String, + onClose: () -> Unit, + modifier: Modifier = Modifier, + tooltipText: String? = null, + content: @Composable () -> Unit +) { + AnimatedVisibility( + visible = visible, + enter = + if (isLandscape) { + slideInHorizontally(initialOffsetX = { it }) + } else { + slideInVertically(initialOffsetY = { it }) + }, + exit = + if (isLandscape) { + slideOutHorizontally(targetOffsetX = { it }) + } else { + slideOutVertically(targetOffsetY = { it }) + }, + modifier = modifier.zIndex(10f), + ) { + Surface( + modifier = + if (isLandscape) { + Modifier.fillMaxHeight().width(INSPECTOR_WIDTH_LANDSCAPE.dp) + } else { + Modifier.fillMaxWidth().height(INSPECTOR_HEIGHT_PORTRAIT.dp) + }, + tonalElevation = 8.dp, + shadowElevation = 8.dp, + color = MaterialTheme.colorScheme.surface, + ) { + Column { + // Title bar with close button + Surface(color = MaterialTheme.colorScheme.surfaceVariant, tonalElevation = 2.dp) { + var showTooltip by remember { mutableStateOf(false) } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp).fillMaxWidth(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + if (tooltipText != null) { + Spacer(Modifier.width(4.dp)) + IconButton(onClick = { showTooltip = true }, modifier = Modifier.size(24.dp)) { + Icon( + Icons.AutoMirrored.Filled.Help, + contentDescription = stringResource(R.string.bg_cd_help), + modifier = Modifier.size(16.dp) + ) + } + } + } + IconButton(onClick = onClose) { + Icon(Icons.Default.Close, contentDescription = stringResource(R.string.bg_content_description_close_inspector)) + } + if (showTooltip && tooltipText != null) { + TooltipDialog( + title = title, + text = tooltipText, + onDismiss = { showTooltip = false } + ) + } + } + } + Box(modifier = Modifier.fillMaxSize()) { + content() + } + } + } + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TestCanvas.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TestCanvas.kt new file mode 100644 index 0000000..f75d1bd --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TestCanvas.kt @@ -0,0 +1,311 @@ +/* + * * Copyright 2026 Google LLC. All rights reserved. + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + */ +@file:OptIn(androidx.ink.brush.ExperimentalInkCustomBrushApi::class) + +package com.example.cahier.developer.brushgraph.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.ink.brush.Brush +import androidx.ink.brush.TextureBitmapStore +import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer +import androidx.ink.strokes.Stroke +import com.example.cahier.core.ui.DrawingSurface +import com.example.cahier.developer.brushgraph.viewmodel.BrushGraphViewModel +import com.example.cahier.developer.brushgraph.data.TutorialAction +import com.example.cahier.developer.brushgraph.data.GraphValidationException +import com.example.cahier.developer.brushgraph.data.ValidationSeverity +import com.example.cahier.core.ui.theme.extendedColorScheme +import androidx.compose.ui.res.stringResource +import com.example.cahier.R + +@Composable +fun TestCanvas( + isInvertedCanvas: Boolean, + strokeList: List, + strokeRenderer: CanvasStrokeRenderer, + textureStore: TextureBitmapStore, + brush: Brush, + modifier: Modifier = Modifier, + onGetNextBrush: () -> Brush, + onStrokesAdded: (List) -> Unit, +) { + Box(modifier = Modifier.fillMaxSize()) { + Text( + stringResource(R.string.bg_test_canvas_draw_prompt), + modifier = Modifier.align(Alignment.Center), + style = MaterialTheme.typography.labelMedium, + color = + if (isInvertedCanvas) { + MaterialTheme.colorScheme.inverseOnSurface + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + DrawingSurface( + strokes = strokeList, + canvasStrokeRenderer = strokeRenderer, + textureStore = textureStore, + onStrokesFinished = onStrokesAdded, + onErase = { _, _ -> }, + onEraseStart = {}, + onEraseEnd = {}, + currentBrush = brush, + onGetNextBrush = onGetNextBrush, + isEraserMode = false, + backgroundImageUri = null, + onStartDrag = {}, + ) + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun CollapsiblePreviewPane( + isPreviewExpanded: Boolean, + isInvertedCanvas: Boolean, + testAutoUpdateStrokes: Boolean, + brushColor: Int, + brushSize: Float, + brush: Brush, + strokeList: List, + strokeRenderer: CanvasStrokeRenderer, + textureStore: TextureBitmapStore, + topIssue: GraphValidationException?, + modifier: Modifier = Modifier, + onGetNextBrush: () -> Brush, + onTogglePreviewExpanded: () -> Unit, + onClearStrokes: () -> Unit, + onToggleCanvasTheme: () -> Unit, + onSetTestAutoUpdateStrokes: (Boolean) -> Unit, + onUpdateTestBrushColor: (Int) -> Unit, + onUpdateTestBrushSize: (Float) -> Unit, + onStrokesAdded: (List) -> Unit, + onChooseColor: (Color, (Color) -> Unit) -> Unit, + onToggleNotificationPane: () -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth()) { + // Toggle Tab (always visible) + Surface( + modifier = + modifier.fillMaxWidth().height(40.dp).clickable { onTogglePreviewExpanded() }, + color = MaterialTheme.colorScheme.surfaceVariant, + tonalElevation = 4.dp, + shadowElevation = 8.dp, + ) { + Box( + modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), + contentAlignment = Alignment.Center, + ) { + Row( + modifier = Modifier.align(Alignment.CenterStart), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + if (isPreviewExpanded) { + Icons.Default.KeyboardArrowDown + } else { + Icons.Default.KeyboardArrowUp + }, + contentDescription = if (isPreviewExpanded) stringResource(R.string.bg_test_canvas_collapse) else stringResource(R.string.bg_test_canvas_expand), + ) + Spacer(Modifier.width(8.dp)) + Text( + stringResource(R.string.bg_test_canvas_title), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + ) + if (isPreviewExpanded) { + Spacer(Modifier.width(16.dp)) + Text( + stringResource(R.string.bg_test_canvas_reset), + modifier = Modifier.clickable { onClearStrokes() }, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.width(16.dp)) + Text( + stringResource(R.string.bg_test_canvas_invert), + modifier = Modifier.clickable { onToggleCanvasTheme() }, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.width(16.dp)) + + // Auto-update toggle + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = testAutoUpdateStrokes, + onCheckedChange = { onSetTestAutoUpdateStrokes(it) } + ) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.bg_auto_update), style = MaterialTheme.typography.labelLarge) + } + Spacer(Modifier.width(16.dp)) + + // Color picker + Box( + modifier = Modifier + .size(20.dp) + .background(Color(brushColor)) + .border(1.dp, MaterialTheme.colorScheme.outline) + .clickable { + onChooseColor(Color(brushColor)) { newColor -> + onUpdateTestBrushColor(newColor.toArgb()) + } + } + ) + Spacer(Modifier.width(16.dp)) + + // Size selector + var sizeExpanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox( + expanded = sizeExpanded, + onExpandedChange = { sizeExpanded = it }, + modifier = Modifier.width(80.dp) + ) { + Text( + text = "${brushSize.toInt()}px", + modifier = Modifier.menuAnchor().clickable { sizeExpanded = true }, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + ExposedDropdownMenu( + expanded = sizeExpanded, + onDismissRequest = { sizeExpanded = false } + ) { + for (size in (2..4 step 1) + (6..10 step 2) + (20..40 step 10) + (50..100 step 25)) { + DropdownMenuItem( + text = { Text(stringResource(R.string.bg_size_px, size)) }, + onClick = { + onUpdateTestBrushSize(size.toFloat()) + sizeExpanded = false + } + ) + } + } + } + + // Top issue, if one is present + if (topIssue != null) { + val isError = topIssue.severity == ValidationSeverity.ERROR + Surface( + color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.extendedColorScheme.warning, + tonalElevation = 2.dp, + modifier = Modifier.weight(1f).fillMaxHeight().clickable { onToggleNotificationPane() } + ) { + Box( + modifier = Modifier.fillMaxSize().padding(start = 16.dp), + contentAlignment = Alignment.CenterStart + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = if (isError) Icons.Default.Error else Icons.Default.Warning, + contentDescription = null, + tint = if (isError) MaterialTheme.colorScheme.onError else MaterialTheme.extendedColorScheme.onWarning, + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(if (isError) R.string.bg_error else R.string.bg_warning, topIssue.displayMessage.asString()), + style = MaterialTheme.typography.labelLarge, + color = if (isError) MaterialTheme.colorScheme.onError else MaterialTheme.extendedColorScheme.onWarning, + fontWeight = FontWeight.Bold, + ) + } + } + } + } + } + } + } + } + + // Expanding Drawer Content + AnimatedVisibility( + visible = isPreviewExpanded, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + Surface( + modifier = + Modifier.fillMaxWidth().height((PREVIEW_HEIGHT_EXPANDED - PREVIEW_HEIGHT_COLLAPSED).dp), + tonalElevation = 8.dp, + color = + if (isInvertedCanvas) { + MaterialTheme.colorScheme.inverseSurface + } else { + MaterialTheme.colorScheme.surface + }, + ) { + TestCanvas( + strokeList = strokeList, + strokeRenderer = strokeRenderer, + textureStore = textureStore, + brush = brush, + isInvertedCanvas = isInvertedCanvas, + onGetNextBrush = onGetNextBrush, + onStrokesAdded = onStrokesAdded, + ) + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b9206c7..4805c40 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -389,7 +389,9 @@ Close Pane Notifications (%1$d) Errors + Error: %1$s Warnings + Warning: %1$s Debug Add Point Remove Point