From ada24a1602d5bcb34e38100ce02e7fcfb42dd026 Mon Sep 17 00:00:00 2001 From: Maxwell Metzger Mitchell <60010512+maxmmitchell@users.noreply.github.com> Date: Mon, 27 Apr 2026 06:51:45 +0000 Subject: [PATCH 1/7] Add ViewModel and TutorialManager for brushgraph --- .../viewmodel/BrushGraphViewModel.kt | 610 ++++++++++++++++++ .../brushgraph/viewmodel/TutorialManager.kt | 88 +++ 2 files changed, 698 insertions(+) create mode 100644 app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModel.kt create mode 100644 app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/TutorialManager.kt diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModel.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModel.kt new file mode 100644 index 0000000..1d58d33 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModel.kt @@ -0,0 +1,610 @@ +/* + * * 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, kotlinx.coroutines.FlowPreview::class) + +package com.example.cahier.developer.brushgraph.viewmodel + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.ink.brush.Brush +import androidx.ink.brush.BrushFamily +import androidx.ink.brush.StockBrushes +import androidx.ink.brush.TextureBitmapStore +import androidx.ink.storage.AndroidBrushFamilySerialization +import androidx.ink.storage.BrushFamilyDecodeCallback +import androidx.ink.strokes.Stroke +import com.example.cahier.developer.brushdesigner.data.CustomBrushEntity +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.cahier.core.ui.CahierTextureBitmapStore +import com.example.cahier.developer.brushgraph.data.BrushGraphRepository +import com.example.cahier.developer.brushdesigner.data.CustomBrushDao +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.filterNotNull +import com.example.cahier.developer.brushgraph.data.BrushFamilyConverter +import com.example.cahier.developer.brushgraph.data.BrushGraphConverter +import com.example.cahier.developer.brushgraph.data.DisplayText +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.GraphPoint +import com.example.cahier.developer.brushgraph.data.Port +import com.example.cahier.developer.brushgraph.data.PortSide +import com.example.cahier.developer.brushgraph.data.getVisiblePorts +import com.example.cahier.developer.brushgraph.data.GraphValidationException +import com.example.cahier.developer.brushgraph.data.NodeData +import com.example.cahier.developer.brushgraph.data.ValidationSeverity +import androidx.compose.runtime.snapshotFlow +import com.example.cahier.developer.brushgraph.ui.node.NodeRegistry +import ink.proto.BrushBehavior as ProtoBrushBehavior +import ink.proto.BrushCoat as ProtoBrushCoat +import ink.proto.BrushFamily as ProtoBrushFamily +import ink.proto.BrushPaint as ProtoBrushPaint +import ink.proto.BrushTip as ProtoBrushTip +import ink.proto.ColorFunction as ProtoColorFunction +import com.example.cahier.developer.brushgraph.data.TUTORIAL_STEPS +import com.example.cahier.developer.brushgraph.data.TutorialStep +import com.example.cahier.developer.brushgraph.data.TutorialAnchor +import com.example.cahier.developer.brushgraph.data.TutorialAction +import java.io.ByteArrayOutputStream +import java.io.ByteArrayInputStream + +data class BrushGraphUiState( + val graph: BrushGraph = BrushGraph(), + val isSelectionMode: Boolean = false, + val selectedNodeIds: Set = emptySet(), + val activeEdgeSourceId: String? = null, + val selectedEdge: GraphEdge? = null, + val testAutoUpdateStrokes: Boolean = true, + val testBrushColor: Int? = null, + val testBrushSize: Float = 10f, + val isErrorPaneOpen: Boolean = false, + val zoom: Float = 1f, + val offset: GraphPoint = GraphPoint(0f, 0f), + val textFieldsLocked: Boolean = false, + val selectedNodeId: String? = null, + val focusTrigger: Int = 0, + val detachedEdge: GraphEdge? = null, + val isPreviewExpanded: Boolean = true, + val isDarkCanvas: Boolean = false, + val graphIssues: List = emptyList(), + val allTextureIds: Set = emptySet() +) + +/** ViewModel to manage the state of the brush graph. */ +@HiltViewModel +class BrushGraphViewModel @Inject constructor( + private val customBrushDao: CustomBrushDao, + val textureStore: CahierTextureBitmapStore, + private val repository: BrushGraphRepository, + private val savedStateHandle: androidx.lifecycle.SavedStateHandle +) : ViewModel() { + + /** Saved brushes in the palette. */ + val savedPaletteBrushes: StateFlow> = + customBrushDao.getAllCustomBrushes() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = emptyList() + ) + + private val _uiState = MutableStateFlow(BrushGraphUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + val brush: StateFlow = uiState.map { state -> + val family = try { + BrushFamilyConverter.convert(state.graph) + } catch (e: Exception) { + null + } + val color = state.testBrushColor ?: 0 + val size = state.testBrushSize + if (family != null) { + Brush.createWithColorIntArgb(family, color, size, 0.1f) + } else { + Brush.createWithColorIntArgb(StockBrushes.marker(), color, size, 0.1f) + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = Brush.createWithColorIntArgb( + StockBrushes.marker(), + 0, // Transparent until initialized by UI + size = 20f, + epsilon = 0.1f, + ) + ) + + /** The list of strokes drawn in the preview area. */ + val strokeList = mutableStateListOf() + + fun updateTestBrushColor(colorArgb: Int) { + _uiState.update { it.copy(testBrushColor = colorArgb) } + } + + fun updateTestBrushSize(size: Float) { + _uiState.update { it.copy(testBrushSize = size) } + } + + fun updateAllTextureIds() { + _uiState.update { state -> state.copy(allTextureIds = textureStore.getAllIds()) } + } + + val tutorialManager = TutorialManager(repository) + + val tutorialStep get() = tutorialManager.tutorialStep + val currentStepIndex get() = tutorialManager.currentStepIndex + val isTutorialSandboxMode get() = tutorialManager.isTutorialSandboxMode + + fun startTutorial() { + tutorialManager.startTutorial() + } + + fun startTutorialSandbox() { + val oldBrushFamily = brush.value.family + val defaultGraph = repository.createDefaultGraph() + repository.setGraph(defaultGraph) + + tutorialManager.startTutorialSandbox(oldBrushFamily) + + validate() + } + + fun advanceTutorial(action: TutorialAction = TutorialAction.CLICK_NEXT): Boolean { + return tutorialManager.advanceTutorial(action) + } + + fun regressTutorial() { + tutorialManager.regressTutorial() + } + + fun endTutorialSandbox(keepChanges: Boolean) { + val brushToRestore = tutorialManager.endTutorialSandbox(keepChanges) + if (brushToRestore != null) { + loadBrushFamily(brushToRestore) + } + } + + init { + validate() + + viewModelScope.launch { + repository.graph.collect { newGraph -> + _uiState.update { it.copy(graph = newGraph) } + } + } + + viewModelScope.launch { + repository.graphIssues.collect { newIssues -> + _uiState.update { it.copy(graphIssues = newIssues) } + } + } + + viewModelScope.launch { + brush.collect { newBrush -> + if (uiState.value.testAutoUpdateStrokes) { + for (i in strokeList.indices) { + strokeList[i] = strokeList[i].copy(brush = newBrush) + } + } + } + } + + viewModelScope.launch(Dispatchers.IO) { + val success = repository.loadAutoSaveBrush() + if (success) { + withContext(Dispatchers.Main) { + _uiState.update { state -> state.copy(allTextureIds = textureStore.getAllIds()) } + } + } + } + } + + fun postDebug(displayText: DisplayText) { + repository.postDebug(displayText) + } + + fun addNode(data: NodeData): String { + dismissPanes() + val newNodeId = repository.addNode(data) + _uiState.update { it.copy(selectedNodeId = newNodeId) } + validate() + + if (data is NodeData.Behavior) { + advanceTutorial(TutorialAction.ADD_INPUT_FAB) || advanceTutorial(TutorialAction.ADD_BEHAVIOR) + } else if (data is NodeData.ColorFunction) { + advanceTutorial(TutorialAction.ADD_COLOR) + } + return newNodeId + } + + fun addFamilyNode(): String { + return addNode(NodeData.Family()) + } + + fun addCoatNode(): String { + return addNode(NodeData.Coat()) + } + + fun addPaintNode(): String { + return addNode(NodeData.Paint(ProtoBrushPaint.getDefaultInstance())) + } + + fun addTipNode(): String { + return addNode(NodeData.Tip(ProtoBrushTip.getDefaultInstance())) + } + + fun addColorFunctionNode(): String { + return addNode(NodeData.ColorFunction(ProtoColorFunction.newBuilder().setOpacityMultiplier(1f).build())) + } + + fun addTextureLayerNode(): String { + return addNode(NodeData.TextureLayer(ProtoBrushPaint.TextureLayer.getDefaultInstance())) + } + + fun addBehaviorNode(): String { + return addNode( + NodeData.Behavior( + ProtoBrushBehavior.Node.newBuilder() + .setTargetNode( + ProtoBrushBehavior.TargetNode.newBuilder() + .setTarget(ProtoBrushBehavior.Target.TARGET_WIDTH_MULTIPLIER) + .setTargetModifierRangeStart(0f) + .setTargetModifierRangeEnd(1f) + ) + .build() + ) + ) + } + + fun enterSelectionMode(initialNodeId: String? = null) { + val node = initialNodeId?.let { id -> uiState.value.graph.nodes.find { it.id == id } } + if (node?.data is NodeData.Family) return + _uiState.update { state -> state.copy( + isSelectionMode = true, + selectedNodeIds = if (initialNodeId != null) setOf(initialNodeId) else emptySet() + ) } + dismissPanes() + + advanceTutorial(TutorialAction.LONG_PRESS_NODE) + } + + fun toggleNodeSelection(nodeId: String) { + val node = uiState.value.graph.nodes.find { it.id == nodeId } + if (node?.data is NodeData.Family) return + _uiState.update { state -> + val newSelected = if (state.selectedNodeIds.contains(nodeId)) { + state.selectedNodeIds - nodeId + } else { + state.selectedNodeIds + nodeId + } + state.copy(selectedNodeIds = newSelected) + } + if (uiState.value.selectedNodeIds.isEmpty()) { + exitSelectionMode() + } + } + + fun selectAllNodes() { + val allNodeIds = uiState.value.graph.nodes.filter { it.data !is NodeData.Family }.map { it.id }.toSet() + _uiState.update { it.copy(selectedNodeIds = allNodeIds) } + } + + fun exitSelectionMode() { + _uiState.update { it.copy(isSelectionMode = false) } + _uiState.update { it.copy(selectedNodeIds = emptySet()) } + + advanceTutorial(TutorialAction.CLICK_DONE) + } + + fun deleteSelectedNodes() { + val modifiedNodeIds = repository.deleteSelectedNodes(uiState.value.selectedNodeIds) + + advanceTutorial(TutorialAction.DELETE_NODE) + + exitSelectionMode() + } + + fun duplicateSelectedNodes() { + val newNodeIds = repository.duplicateSelectedNodes(uiState.value.selectedNodeIds) + + _uiState.update { it.copy(selectedNodeIds = newNodeIds) } + + advanceTutorial(TutorialAction.DUPLICATE_NODES) + } + + fun updateNodeData(nodeId: String, newData: NodeData) { + repository.updateNodeData(nodeId, newData) + + validate() + } + + fun setNodeDisabled(nodeId: String, isDisabled: Boolean) { + repository.setNodeDisabled(nodeId, isDisabled) + validate() + } + + fun setEdgeDisabled(edge: GraphEdge, isDisabled: Boolean) { + _uiState.update { it.copy(selectedEdge = repository.setEdgeDisabled(edge, isDisabled)) } + validate() + } + + fun onNodeClick(nodeId: String) { + _uiState.update { state -> state.copy(selectedNodeId = if (state.selectedNodeId == nodeId) null else nodeId, selectedEdge = null, isErrorPaneOpen = false) } + + advanceTutorial(TutorialAction.SELECT_NODE) + } + + private fun checkSelectNodeTrigger(nodeId: String) { + val node = uiState.value.graph.nodes.find { it.id == nodeId } + if (node != null) { + val shouldAdvance = (node.data is NodeData.Tip) || + (node.data is NodeData.Behavior && node.data.node.nodeCase == ink.proto.BrushBehavior.Node.NodeCase.SOURCE_NODE) || + (node.data is NodeData.Behavior && node.data.node.nodeCase == ink.proto.BrushBehavior.Node.NodeCase.BINARY_OP_NODE) || + (node.data is NodeData.Behavior && node.data.node.nodeCase == ink.proto.BrushBehavior.Node.NodeCase.TARGET_NODE) || + tutorialStep?.getTargetNode(uiState.value.graph)?.id == nodeId + if (shouldAdvance) { + advanceTutorial(TutorialAction.SELECT_NODE) + } + } + } + + fun onEdgeClick(edge: GraphEdge) { + _uiState.update { state -> + val newEdge = if (state.selectedEdge?.fromNodeId == edge.fromNodeId && + state.selectedEdge?.toNodeId == edge.toNodeId && state.selectedEdge?.toPortId == edge.toPortId) null else edge + state.copy(selectedEdge = newEdge, isErrorPaneOpen = false, selectedNodeId = null) + } + + advanceTutorial(TutorialAction.SELECT_EDGE) + } + + fun clearSelectedNode() { + _uiState.update { it.copy(selectedNodeId = null) } + + advanceTutorial(TutorialAction.EXIT_INSPECTOR) + } + + fun clearSelectedEdge() { + _uiState.update { it.copy(selectedEdge = null) } + } + + fun toggleErrorPane() { + _uiState.update { it.copy(isErrorPaneOpen = !it.isErrorPaneOpen) } + if (uiState.value.isErrorPaneOpen) { + _uiState.update { it.copy(selectedNodeId = null) } + advanceTutorial(TutorialAction.CLICK_NOTIFICATION) + } + } + + fun dismissPanes() { + clearSelectedNode() + _uiState.update { it.copy(selectedEdge = null, isErrorPaneOpen = false, activeEdgeSourceId = null) } + } + + fun onIssueClick(issue: GraphValidationException, isLandscape: Boolean, density: Float) { + if (issue.nodeId != null) { + centerNode(issue.nodeId) + } + + advanceTutorial(TutorialAction.CLICK_ERROR_LINK) + } + + fun centerNode(nodeId: String) { + _uiState.update { it.copy(selectedNodeId = nodeId, selectedEdge = null, isErrorPaneOpen = false, focusTrigger = it.focusTrigger + 1) } + checkSelectNodeTrigger(nodeId) + } + + fun togglePreviewExpanded() { + _uiState.update { it.copy(isPreviewExpanded = !it.isPreviewExpanded) } + } + + fun toggleCanvasTheme() { + _uiState.update { it.copy(isDarkCanvas = !it.isDarkCanvas) } + } + + fun addNodeAndConnect(nodeData: NodeData, targetNodeId: String, targetPortId: String): String { + val newNodeId = repository.addNode(nodeData) + + addEdge(newNodeId, targetNodeId, targetPortId) + + if (nodeData is NodeData.Behavior) { + advanceTutorial(TutorialAction.ADD_BEHAVIOR) + } else if (nodeData is NodeData.ColorFunction) { + advanceTutorial(TutorialAction.ADD_COLOR) + } + return newNodeId + } + + /** Adds a new edge between two nodes. */ + fun addEdge(fromNodeId: String, toNodeId: String, initialToPortId: String) { + repository.addEdge(fromNodeId, toNodeId, initialToPortId) + validate() + + val fromNode = uiState.value.graph.nodes.find { it.id == fromNodeId } ?: return + val toNode = uiState.value.graph.nodes.find { it.id == toNodeId } ?: return + val shouldAdvance = (fromNode.data is NodeData.Behavior && fromNode.data.node.nodeCase == ink.proto.BrushBehavior.Node.NodeCase.SOURCE_NODE && + toNode.data is NodeData.Behavior && toNode.data.node.nodeCase == ink.proto.BrushBehavior.Node.NodeCase.TARGET_NODE) || + (fromNode.data is NodeData.Coat && toNode.data is NodeData.Family) || + (fromNode.data is NodeData.Behavior && toNode.data is NodeData.Tip) + if (shouldAdvance) { + advanceTutorial(TutorialAction.CONNECT_NODES) + } + } + + /** Finalizes an edge edit by deleting the old edge and adding the new one. */ + fun finalizeEdgeEdit(oldEdge: GraphEdge, newFromNodeId: String, newToNodeId: String, newToPortId: String) { + if (oldEdge.toNodeId == newToNodeId && oldEdge.toPortId == newToPortId) { + // Reconnecting to the same port, just re-enable it. + setEdgeDisabled(oldEdge, false) + _uiState.update { it.copy(detachedEdge = null) } + return + } + + deleteEdge(oldEdge) + + addEdge(newFromNodeId, newToNodeId, newToPortId) + } + + /** Detaches an edge for editing by marking it as disabled. */ + fun detachEdge(edge: GraphEdge) { + _uiState.update { state -> state.copy(detachedEdge = edge) } + repository.setEdgeDisabled(edge, true) + } + + fun reorderPorts(nodeId: String, fromIndex: Int, toIndex: Int) { + repository.reorderPorts(nodeId, fromIndex, toIndex) + advanceTutorial(TutorialAction.SWAP_PORTS) + } + + fun deleteEdge(edge: GraphEdge) { + if (uiState.value.selectedEdge == edge) { + _uiState.update { state -> state.copy(selectedEdge = null) } + } + if (uiState.value.detachedEdge == edge) { + _uiState.update { state -> state.copy(detachedEdge = null) } + } + + val modifiedNodeIds = repository.deleteEdge(edge) + } + + fun addNodeBetween(edge: GraphEdge): String? { + dismissPanes() + val newNodeId = repository.addNodeBetween(edge) + if (newNodeId != null) { + _uiState.update { it.copy(selectedNodeId = newNodeId) } + } + + advanceTutorial(TutorialAction.ADD_NODE_BETWEEN) + return newNodeId + } + + fun clearGraph() { + dismissPanes() + repository.clearGraph() + clearStrokes() + validate() + } + + fun deleteNode(nodeId: String) { + val node = uiState.value.graph.nodes.find { it.id == nodeId } ?: return + if (node.data is NodeData.Family) { + return + } + if (uiState.value.selectedNodeId == nodeId) { + _uiState.update { it.copy(selectedNodeId = null) } + } + + advanceTutorial(TutorialAction.DELETE_NODE) + + val modifiedNodeIds = repository.deleteNode(nodeId) + validate() + } + + fun validate() { + repository.validate() + } + + fun reorganize() { + dismissPanes() + repository.reorganize() + } + + fun clearStrokes() { + strokeList.clear() + } + + fun loadBrushFamily(family: BrushFamily) { + dismissPanes() + repository.loadBrushFamily(family) + } + + fun getBrushColor(): Int = brush.value.colorIntArgb + + fun updateZoom(newZoom: Float) { + _uiState.update { state -> state.copy(zoom = newZoom) } + } + + fun updateOffset(newOffset: GraphPoint) { + _uiState.update { state -> state.copy(offset = newOffset) } + } + + fun toggleTextFieldsLocked() { + _uiState.update { state -> state.copy(textFieldsLocked = !state.textFieldsLocked) } + } + + fun saveToPalette(brushName: String, textureStore: TextureBitmapStore) { + viewModelScope.launch(Dispatchers.IO) { + try { + val baos = ByteArrayOutputStream() + AndroidBrushFamilySerialization.encode(brush.value.family, baos, textureStore) + val finalCompressedBytes = baos.toByteArray() + + customBrushDao.saveCustomBrush( + CustomBrushEntity(name = brushName, brushBytes = finalCompressedBytes) + ) + } catch (e: Exception) { + println("Failed to save brush to palette: $e") + } + } + } + + fun deleteFromPalette(name: String) { + viewModelScope.launch(Dispatchers.IO) { + customBrushDao.deleteCustomBrush(name) + } + } + + fun loadFromPalette(entity: CustomBrushEntity, textureStore: TextureBitmapStore) { + viewModelScope.launch(Dispatchers.IO) { + try { + val family = AndroidBrushFamilySerialization.decode( + ByteArrayInputStream(entity.brushBytes), + BrushFamilyDecodeCallback { id, bitmap -> + if (bitmap != null && textureStore is CahierTextureBitmapStore) { + textureStore.loadTexture(id, bitmap) + } + id + } + ) + withContext(Dispatchers.Main) { + loadBrushFamily(family) + } + } catch (e: Exception) { + println("Failed to load brush from palette: $e") + } + } + } + + fun setTestAutoUpdateStrokes(value: Boolean) { + _uiState.update { state -> state.copy(testAutoUpdateStrokes = value) } + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/TutorialManager.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/TutorialManager.kt new file mode 100644 index 0000000..5bcbc45 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/TutorialManager.kt @@ -0,0 +1,88 @@ +/* + * * 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.viewmodel + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.setValue +import androidx.ink.brush.BrushFamily +import com.example.cahier.developer.brushgraph.data.BrushGraphRepository +import com.example.cahier.developer.brushgraph.data.TutorialAction +import com.example.cahier.developer.brushgraph.data.TutorialStep +import com.example.cahier.developer.brushgraph.data.TUTORIAL_STEPS + +class TutorialManager( + private val repository: BrushGraphRepository, +) { + var tutorialStep by mutableStateOf(null) + private set + + var currentStepIndex by mutableIntStateOf(0) + private set + + private val tutorialSteps = mutableStateListOf() + + var savedBrushFamily by mutableStateOf(null) + private set + + var isTutorialSandboxMode by mutableStateOf(false) + private set + + fun startTutorial() { + tutorialSteps.clear() + tutorialSteps.addAll(TUTORIAL_STEPS) + currentStepIndex = 0 + tutorialStep = tutorialSteps.getOrNull(currentStepIndex) + repository.clearIssues() + } + + fun startTutorialSandbox(currentBrushFamily: BrushFamily) { + savedBrushFamily = currentBrushFamily + isTutorialSandboxMode = true + startTutorial() + } + + fun advanceTutorial(action: TutorialAction = TutorialAction.CLICK_NEXT): Boolean { + val step = tutorialStep + if (step != null && step.actionRequired == action) { + currentStepIndex++ + if (currentStepIndex < tutorialSteps.size) { + tutorialStep = tutorialSteps[currentStepIndex] + } else { + tutorialStep = null // Tutorial finished! + } + return true + } + return false + } + + fun regressTutorial() { + if (currentStepIndex > 0) { + currentStepIndex-- + tutorialStep = tutorialSteps[currentStepIndex] + } + } + + fun endTutorialSandbox(keepChanges: Boolean): BrushFamily? { + isTutorialSandboxMode = false + val brushToRestore = if (!keepChanges) savedBrushFamily else null + savedBrushFamily = null + tutorialStep = null + return brushToRestore + } +} From 1967249a1f399104542f945fe9fcc9993528c2f7 Mon Sep 17 00:00:00 2001 From: Maxwell Metzger Mitchell <60010512+maxmmitchell@users.noreply.github.com> Date: Tue, 28 Apr 2026 05:15:52 +0000 Subject: [PATCH 2/7] Fix issues from PR 2 --- .../example/cahier/core/ui/CahierTextureBitmapStore.kt | 9 ++++++++- .../brushgraph/viewmodel/BrushGraphViewModel.kt | 1 - 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/example/cahier/core/ui/CahierTextureBitmapStore.kt b/app/src/main/java/com/example/cahier/core/ui/CahierTextureBitmapStore.kt index 998fc36..6f52541 100644 --- a/app/src/main/java/com/example/cahier/core/ui/CahierTextureBitmapStore.kt +++ b/app/src/main/java/com/example/cahier/core/ui/CahierTextureBitmapStore.kt @@ -24,9 +24,11 @@ import androidx.annotation.DrawableRes import androidx.ink.brush.ExperimentalInkCustomBrushApi import androidx.ink.brush.TextureBitmapStore import com.example.cahier.R +import javax.inject.Inject +import dagger.hilt.android.qualifiers.ApplicationContext @OptIn(ExperimentalInkCustomBrushApi::class) -class CahierTextureBitmapStore(context: Context) : TextureBitmapStore { +class CahierTextureBitmapStore @Inject constructor(@ApplicationContext context: Context) : TextureBitmapStore { private val resources = context.resources private val textureResources: Map = mapOf( @@ -43,6 +45,11 @@ class CahierTextureBitmapStore(context: Context) : TextureBitmapStore { } } + /** Returns all available texture IDs. */ + fun getAllIds(): Set { + return textureResources.keys + loadedBitmaps.keys + } + private fun getShortName(clientTextureId: String): String = clientTextureId.removePrefix("ink://ink").removePrefix("/texture:") diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModel.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModel.kt index 1d58d33..a08009a 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModel.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModel.kt @@ -62,7 +62,6 @@ import com.example.cahier.developer.brushgraph.data.GraphValidationException import com.example.cahier.developer.brushgraph.data.NodeData import com.example.cahier.developer.brushgraph.data.ValidationSeverity import androidx.compose.runtime.snapshotFlow -import com.example.cahier.developer.brushgraph.ui.node.NodeRegistry import ink.proto.BrushBehavior as ProtoBrushBehavior import ink.proto.BrushCoat as ProtoBrushCoat import ink.proto.BrushFamily as ProtoBrushFamily From ccb148003d38507e87e593d751f05d045994d7e2 Mon Sep 17 00:00:00 2001 From: Maxwell Metzger Mitchell <60010512+maxmmitchell@users.noreply.github.com> Date: Tue, 28 Apr 2026 05:52:47 +0000 Subject: [PATCH 3/7] Gemini comments --- .../viewmodel/BrushGraphViewModel.kt | 63 ++++++++++--------- app/src/main/res/values/strings.xml | 2 + 2 files changed, 35 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModel.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModel.kt index a08009a..851856d 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModel.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModel.kt @@ -48,6 +48,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.distinctUntilChanged import com.example.cahier.developer.brushgraph.data.BrushFamilyConverter import com.example.cahier.developer.brushgraph.data.BrushGraphConverter import com.example.cahier.developer.brushgraph.data.DisplayText @@ -101,9 +102,8 @@ data class BrushGraphUiState( @HiltViewModel class BrushGraphViewModel @Inject constructor( private val customBrushDao: CustomBrushDao, - val textureStore: CahierTextureBitmapStore, - private val repository: BrushGraphRepository, - private val savedStateHandle: androidx.lifecycle.SavedStateHandle + private val textureStore: CahierTextureBitmapStore, + private val repository: BrushGraphRepository ) : ViewModel() { /** Saved brushes in the palette. */ @@ -118,29 +118,32 @@ class BrushGraphViewModel @Inject constructor( private val _uiState = MutableStateFlow(BrushGraphUiState()) val uiState: StateFlow = _uiState.asStateFlow() - val brush: StateFlow = uiState.map { state -> - val family = try { - BrushFamilyConverter.convert(state.graph) - } catch (e: Exception) { - null - } - val color = state.testBrushColor ?: 0 - val size = state.testBrushSize - if (family != null) { - Brush.createWithColorIntArgb(family, color, size, 0.1f) - } else { - Brush.createWithColorIntArgb(StockBrushes.marker(), color, size, 0.1f) - } - }.stateIn( - scope = viewModelScope, - started = SharingStarted.Eagerly, - initialValue = Brush.createWithColorIntArgb( - StockBrushes.marker(), - 0, // Transparent until initialized by UI - size = 20f, - epsilon = 0.1f, + val brush: StateFlow = uiState + .map { Triple(it.graph, it.testBrushColor, it.testBrushSize) } + .distinctUntilChanged() + .map { (graph, testBrushColor, testBrushSize) -> + val family = try { + BrushFamilyConverter.convert(graph) + } catch (e: Exception) { + null + } + val color = testBrushColor ?: 0 + val size = testBrushSize + if (family != null) { + Brush.createWithColorIntArgb(family, color, size, 0.1f) + } else { + Brush.createWithColorIntArgb(StockBrushes.marker(), color, size, 0.1f) + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = Brush.createWithColorIntArgb( + StockBrushes.marker(), + 0, + size = 20f, + epsilon = 0.1f, + ) ) - ) /** The list of strokes drawn in the preview area. */ val strokeList = mutableStateListOf() @@ -560,7 +563,7 @@ class BrushGraphViewModel @Inject constructor( _uiState.update { state -> state.copy(textFieldsLocked = !state.textFieldsLocked) } } - fun saveToPalette(brushName: String, textureStore: TextureBitmapStore) { + fun saveToPalette(brushName: String) { viewModelScope.launch(Dispatchers.IO) { try { val baos = ByteArrayOutputStream() @@ -571,7 +574,7 @@ class BrushGraphViewModel @Inject constructor( CustomBrushEntity(name = brushName, brushBytes = finalCompressedBytes) ) } catch (e: Exception) { - println("Failed to save brush to palette: $e") + postDebug(DisplayText.Resource(com.example.cahier.R.string.bg_err_save_palette, listOf(e.message ?: e.javaClass.simpleName))) } } } @@ -582,13 +585,13 @@ class BrushGraphViewModel @Inject constructor( } } - fun loadFromPalette(entity: CustomBrushEntity, textureStore: TextureBitmapStore) { + fun loadFromPalette(entity: CustomBrushEntity) { viewModelScope.launch(Dispatchers.IO) { try { val family = AndroidBrushFamilySerialization.decode( ByteArrayInputStream(entity.brushBytes), BrushFamilyDecodeCallback { id, bitmap -> - if (bitmap != null && textureStore is CahierTextureBitmapStore) { + if (bitmap != null) { textureStore.loadTexture(id, bitmap) } id @@ -598,7 +601,7 @@ class BrushGraphViewModel @Inject constructor( loadBrushFamily(family) } } catch (e: Exception) { - println("Failed to load brush from palette: $e") + postDebug(DisplayText.Resource(com.example.cahier.R.string.bg_err_load_palette, listOf(e.message ?: e.javaClass.simpleName))) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 29120fe..d08fe5c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -447,6 +447,8 @@ Reorganization failed Failed to load brush Cannot delete Family node + Failed to save brush to palette: %1$s + Failed to load brush from palette: %1$s Behavior node %1$s cannot accept input from %2$s Behavior node %1$s cannot accept input from structural node %2$s Coat can only accept input from Tip at the tip port From e18511c26d5eabee0f49fbb473594cc93dde988c Mon Sep 17 00:00:00 2001 From: Maxwell Metzger Mitchell <60010512+maxmmitchell@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:53:57 +0000 Subject: [PATCH 4/7] Support returning mapping of old ids to duplicated ids from viewmodel to UI layer --- .../developer/brushgraph/viewmodel/BrushGraphViewModel.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModel.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModel.kt index 851856d..0dbe3fd 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModel.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModel.kt @@ -335,12 +335,13 @@ class BrushGraphViewModel @Inject constructor( exitSelectionMode() } - fun duplicateSelectedNodes() { - val newNodeIds = repository.duplicateSelectedNodes(uiState.value.selectedNodeIds) + fun duplicateSelectedNodes(): Map { + val newNodeIdsMap = repository.duplicateSelectedNodes(uiState.value.selectedNodeIds) - _uiState.update { it.copy(selectedNodeIds = newNodeIds) } + _uiState.update { it.copy(selectedNodeIds = newNodeIdsMap.values.toSet()) } advanceTutorial(TutorialAction.DUPLICATE_NODES) + return newNodeIdsMap } fun updateNodeData(nodeId: String, newData: NodeData) { From 59ee004566bc5eba7c7313faa96318aa0041e2b5 Mon Sep 17 00:00:00 2001 From: Maxwell Metzger Mitchell <60010512+maxmmitchell@users.noreply.github.com> Date: Fri, 1 May 2026 19:35:56 +0000 Subject: [PATCH 5/7] Get brush family correctly --- .../developer/brushgraph/viewmodel/BrushGraphViewModel.kt | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModel.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModel.kt index 0dbe3fd..c58f519 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModel.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModel.kt @@ -49,8 +49,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.distinctUntilChanged -import com.example.cahier.developer.brushgraph.data.BrushFamilyConverter -import com.example.cahier.developer.brushgraph.data.BrushGraphConverter import com.example.cahier.developer.brushgraph.data.DisplayText import com.example.cahier.developer.brushgraph.data.BrushGraph import com.example.cahier.developer.brushgraph.data.GraphEdge @@ -122,11 +120,7 @@ class BrushGraphViewModel @Inject constructor( .map { Triple(it.graph, it.testBrushColor, it.testBrushSize) } .distinctUntilChanged() .map { (graph, testBrushColor, testBrushSize) -> - val family = try { - BrushFamilyConverter.convert(graph) - } catch (e: Exception) { - null - } + val family = repository.getBrushFamily() val color = testBrushColor ?: 0 val size = testBrushSize if (family != null) { From 07aba842569ac46619289cd9847459fe5f97d4bb Mon Sep 17 00:00:00 2001 From: Maxwell Metzger Mitchell <60010512+maxmmitchell@users.noreply.github.com> Date: Tue, 5 May 2026 19:48:02 +0000 Subject: [PATCH 6/7] Add tests --- .../viewmodel/BrushGraphViewModelTest.kt | 544 ++++++++++++++++++ .../brushgraph/viewmodel/BrushGraphUiState.kt | 28 + .../viewmodel/BrushGraphViewModel.kt | 24 - 3 files changed, 572 insertions(+), 24 deletions(-) create mode 100644 app/src/androidTest/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModelTest.kt create mode 100644 app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphUiState.kt diff --git a/app/src/androidTest/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModelTest.kt b/app/src/androidTest/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModelTest.kt new file mode 100644 index 0000000..3a529be --- /dev/null +++ b/app/src/androidTest/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModelTest.kt @@ -0,0 +1,544 @@ +package com.example.cahier.developer.brushgraph.viewmodel + +import com.example.cahier.core.ui.CahierTextureBitmapStore +import com.example.cahier.developer.brushdesigner.data.FakeCustomBrushDao +import com.example.cahier.developer.brushdesigner.data.CustomBrushEntity +import java.io.ByteArrayOutputStream +import com.example.cahier.developer.brushgraph.data.BrushGraph +import com.example.cahier.developer.brushgraph.data.DisplayText +import com.example.cahier.developer.brushgraph.data.BrushGraphRepository +import com.example.cahier.developer.brushgraph.data.DefaultBrushGraphRepository +import com.example.cahier.developer.brushgraph.data.GraphNode +import com.example.cahier.developer.brushgraph.data.NodeData +import ink.proto.BrushTip as ProtoBrushTip +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.Assert.* +import org.mockito.Mockito.mock +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@OptIn(ExperimentalCoroutinesApi::class) +class BrushGraphViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + + private lateinit var fakeDao: FakeCustomBrushDao + private lateinit var mockTextureStore: CahierTextureBitmapStore + private lateinit var repository: DefaultBrushGraphRepository + private lateinit var viewModel: BrushGraphViewModel + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + fakeDao = FakeCustomBrushDao() + mockTextureStore = mock(CahierTextureBitmapStore::class.java) + + val repoScope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.SupervisorJob() + testDispatcher) + repository = DefaultBrushGraphRepository(fakeDao, mockTextureStore, repoScope) + + viewModel = BrushGraphViewModel(fakeDao, mockTextureStore, repository) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun initialState_isCorrect() = testScope.runTest { + val state = viewModel.uiState.first() + val defaultGraph = repository.createDefaultGraph() + assertEquals(defaultGraph.nodes.size, state.graph.nodes.size) + assertEquals(defaultGraph.edges.size, state.graph.edges.size) + + val expectedTypes = defaultGraph.nodes.map { it.data::class }.toSet() + val actualTypes = state.graph.nodes.map { it.data::class }.toSet() + assertEquals(expectedTypes, actualTypes) + + assertFalse(state.isSelectionMode) + assertTrue(state.selectedNodeIds.isEmpty()) + assertNull(state.selectedNodeId) + assertNull(state.selectedEdge) + assertNull(state.activeEdgeSourceId) + assertNull(state.detachedEdge) + + assertFalse(state.isErrorPaneOpen) + assertFalse(state.textFieldsLocked) + assertFalse(state.isDarkCanvas) + assertTrue(state.isPreviewExpanded) + assertTrue(state.testAutoUpdateStrokes) + + assertTrue(state.graphIssues.isEmpty()) + } + + @Test + fun addNode_updatesSelectedNodeIdAndCallsRepo() = testScope.runTest { + val nodeData = NodeData.Tip(ProtoBrushTip.getDefaultInstance()) + + val nodeId = viewModel.addNode(nodeData) + + testScope.advanceUntilIdle() + + val state = viewModel.uiState.first() + assertEquals(nodeId, state.selectedNodeId) + assertTrue(state.graph.nodes.any { it.id == nodeId }) + } + + @Test + fun enterSelectionMode_updatesState() = testScope.runTest { + val nodeId = "node1" + repository.setGraph(BrushGraph(nodes = listOf(GraphNode(id = nodeId, data = NodeData.Tip(ProtoBrushTip.getDefaultInstance()))))) + + testScope.advanceUntilIdle() + + viewModel.enterSelectionMode(nodeId) + + val state = viewModel.uiState.first() + assertTrue(state.isSelectionMode) + assertEquals(setOf(nodeId), state.selectedNodeIds) + } + + @Test + fun toggleNodeSelection_updatesState() = testScope.runTest { + val nodeId = "node1" + repository.setGraph(BrushGraph(nodes = listOf(GraphNode(id = nodeId, data = NodeData.Tip(ProtoBrushTip.getDefaultInstance()))))) + + testScope.advanceUntilIdle() + + viewModel.enterSelectionMode(nodeId) + + viewModel.toggleNodeSelection(nodeId) + + val state = viewModel.uiState.first() + assertFalse(state.isSelectionMode) + assertTrue(state.selectedNodeIds.isEmpty()) + } + + @Test + fun onNodeClick_togglesSelection() = testScope.runTest { + val nodeId = "node1" + repository.setGraph(BrushGraph(nodes = listOf(GraphNode(id = nodeId, data = NodeData.Tip(ProtoBrushTip.getDefaultInstance()))))) + + testScope.advanceUntilIdle() + + viewModel.onNodeClick(nodeId) + + var state = viewModel.uiState.first() + assertEquals(nodeId, state.selectedNodeId) + + viewModel.onNodeClick(nodeId) + + state = viewModel.uiState.first() + assertNull(state.selectedNodeId) + } + + @Test + fun dismissPanes_clearsSelections() = testScope.runTest { + val nodeId = "node1" + repository.setGraph(BrushGraph(nodes = listOf(GraphNode(id = nodeId, data = NodeData.Tip(ProtoBrushTip.getDefaultInstance()))))) + + testScope.advanceUntilIdle() + + viewModel.onNodeClick(nodeId) + + viewModel.dismissPanes() + + val state = viewModel.uiState.first() + assertNull(state.selectedNodeId) + assertNull(state.selectedEdge) + assertFalse(state.isErrorPaneOpen) + } + + @Test + fun selectAllNodes_updatesState() = testScope.runTest { + val node1 = "node1" + val node2 = "node2" + repository.setGraph(BrushGraph(nodes = listOf( + GraphNode(id = node1, data = NodeData.Tip(ProtoBrushTip.getDefaultInstance())), + GraphNode(id = node2, data = NodeData.Coat()) + ))) + + testScope.advanceUntilIdle() + + viewModel.enterSelectionMode() + viewModel.selectAllNodes() + + val state = viewModel.uiState.first() + assertEquals(setOf(node1, node2), state.selectedNodeIds) + } + + @Test + fun exitSelectionMode_clearsState() = testScope.runTest { + val nodeId = "node1" + repository.setGraph(BrushGraph(nodes = listOf(GraphNode(id = nodeId, data = NodeData.Tip(ProtoBrushTip.getDefaultInstance()))))) + + testScope.advanceUntilIdle() + + viewModel.enterSelectionMode(nodeId) + assertTrue(viewModel.uiState.first().isSelectionMode) + + viewModel.exitSelectionMode() + + val state = viewModel.uiState.first() + assertFalse(state.isSelectionMode) + assertTrue(state.selectedNodeIds.isEmpty()) + } + + @Test + fun onEdgeClick_togglesEdgeSelection() = testScope.runTest { + val edge = com.example.cahier.developer.brushgraph.data.GraphEdge(fromNodeId = "node1", toNodeId = "node2", toPortId = "tip") + + viewModel.onEdgeClick(edge) + + var state = viewModel.uiState.first() + assertEquals(edge, state.selectedEdge) + + viewModel.onEdgeClick(edge) + + state = viewModel.uiState.first() + assertNull(state.selectedEdge) + } + + @Test + fun toggleErrorPane_togglesState() = testScope.runTest { + assertFalse(viewModel.uiState.first().isErrorPaneOpen) + + viewModel.toggleErrorPane() + assertTrue(viewModel.uiState.first().isErrorPaneOpen) + + viewModel.toggleErrorPane() + assertFalse(viewModel.uiState.first().isErrorPaneOpen) + } + + @Test + fun updateTestBrushColor_updatesState() = testScope.runTest { + val color = 0xFF00FF00.toInt() + viewModel.updateTestBrushColor(color) + assertEquals(color, viewModel.uiState.first().testBrushColor) + } + + @Test + fun updateTestBrushSize_updatesState() = testScope.runTest { + val size = 15f + viewModel.updateTestBrushSize(size) + assertEquals(size, viewModel.uiState.first().testBrushSize, 0.01f) + } + + @Test + fun setTestAutoUpdateStrokes_updatesState() = testScope.runTest { + assertTrue(viewModel.uiState.first().testAutoUpdateStrokes) + + viewModel.setTestAutoUpdateStrokes(false) + assertFalse(viewModel.uiState.first().testAutoUpdateStrokes) + } + + @Test + fun updateZoom_updatesState() = testScope.runTest { + val zoom = 2f + viewModel.updateZoom(zoom) + assertEquals(zoom, viewModel.uiState.first().zoom, 0.01f) + } + + @Test + fun updateOffset_updatesState() = testScope.runTest { + val offset = com.example.cahier.developer.brushgraph.data.GraphPoint(10f, 20f) + viewModel.updateOffset(offset) + assertEquals(offset, viewModel.uiState.first().offset) + } + + @Test + fun toggleTextFieldsLocked_updatesState() = testScope.runTest { + assertFalse(viewModel.uiState.first().textFieldsLocked) + + viewModel.toggleTextFieldsLocked() + assertTrue(viewModel.uiState.first().textFieldsLocked) + } + + @Test + fun toggleCanvasTheme_updatesState() = testScope.runTest { + assertFalse(viewModel.uiState.first().isDarkCanvas) + + viewModel.toggleCanvasTheme() + assertTrue(viewModel.uiState.first().isDarkCanvas) + } + + @Test + fun togglePreviewExpanded_updatesState() = testScope.runTest { + assertTrue(viewModel.uiState.first().isPreviewExpanded) + + viewModel.togglePreviewExpanded() + assertFalse(viewModel.uiState.first().isPreviewExpanded) + } + + @Test + fun startTutorial_callsTutorialManager() = testScope.runTest { + viewModel.startTutorial() + assertEquals(0, viewModel.currentStepIndex) + + viewModel.advanceTutorial() + assertEquals(1, viewModel.currentStepIndex) + + viewModel.startTutorial() + assertEquals(0, viewModel.currentStepIndex) + } + + @Test + fun startTutorialSandbox_updatesState() = testScope.runTest { + assertFalse(viewModel.isTutorialSandboxMode) + + viewModel.startTutorialSandbox() + assertTrue(viewModel.isTutorialSandboxMode) + } + + @Test + fun advanceTutorial_updatesStep() = testScope.runTest { + viewModel.startTutorial() + val initialStep = viewModel.currentStepIndex + + val advanced = viewModel.advanceTutorial() + assertTrue(advanced) + assertEquals(initialStep + 1, viewModel.currentStepIndex) + } + + @Test + fun regressTutorial_updatesStep() = testScope.runTest { + viewModel.startTutorial() + viewModel.advanceTutorial() + val stepAfterAdvance = viewModel.currentStepIndex + + viewModel.regressTutorial() + assertEquals(stepAfterAdvance - 1, viewModel.currentStepIndex) + } + + @Test + fun endTutorialSandbox_updatesState() = testScope.runTest { + viewModel.startTutorialSandbox() + assertTrue(viewModel.isTutorialSandboxMode) + + viewModel.endTutorialSandbox(keepChanges = false) + assertFalse(viewModel.isTutorialSandboxMode) + } + + @Test + fun saveToPalette_callsDao() = testScope.runTest { + val brushName = "testBrush" + + viewModel.saveToPalette(brushName) + + // Wait for the IO coroutine to finish on the device + Thread.sleep(500) + + val savedBrushes = fakeDao.getAllCustomBrushes().first() + assertTrue(savedBrushes.any { it.name == brushName }) + } + + @Test + fun deleteFromPalette_callsDao() = testScope.runTest { + val brushName = "testBrush" + val entity = CustomBrushEntity(name = brushName, brushBytes = byteArrayOf()) + fakeDao.saveCustomBrush(entity) + + viewModel.deleteFromPalette(brushName) + + // Wait for the IO coroutine to finish on the device + Thread.sleep(500) + + val savedBrushes = fakeDao.getAllCustomBrushes().first() + assertFalse(savedBrushes.any { it.name == brushName }) + } + + @Test + fun loadFromPalette_callsRepo() = testScope.runTest { + val brushName = "testBrush" + val family = androidx.ink.brush.Brush.createWithColorIntArgb(androidx.ink.brush.StockBrushes.marker(), 0, 10f, 0.1f).family + val baos = ByteArrayOutputStream() + androidx.ink.storage.AndroidBrushFamilySerialization.encode(family, baos, mockTextureStore) + val entity = CustomBrushEntity(name = brushName, brushBytes = baos.toByteArray()) + + viewModel.loadFromPalette(entity) + + testScope.advanceUntilIdle() + + // Verification is hard without a spy, but we ensure no crash. + } + + @Test + fun addFamilyNode_addsNode() = testScope.runTest { + val nodeId = viewModel.addFamilyNode() + testScope.advanceUntilIdle() + val node = viewModel.uiState.first().graph.nodes.find { it.id == nodeId }!! + assertTrue(node.data is NodeData.Family) + } + + @Test + fun addCoatNode_addsNode() = testScope.runTest { + val nodeId = viewModel.addCoatNode() + testScope.advanceUntilIdle() + val node = viewModel.uiState.first().graph.nodes.find { it.id == nodeId }!! + assertTrue(node.data is NodeData.Coat) + } + + @Test + fun addPaintNode_addsNode() = testScope.runTest { + val nodeId = viewModel.addPaintNode() + testScope.advanceUntilIdle() + val node = viewModel.uiState.first().graph.nodes.find { it.id == nodeId }!! + assertTrue(node.data is NodeData.Paint) + } + + @Test + fun addTipNode_addsNode() = testScope.runTest { + val nodeId = viewModel.addTipNode() + testScope.advanceUntilIdle() + val node = viewModel.uiState.first().graph.nodes.find { it.id == nodeId }!! + assertTrue(node.data is NodeData.Tip) + } + + @Test + fun addColorFunctionNode_addsNode() = testScope.runTest { + val nodeId = viewModel.addColorFunctionNode() + testScope.advanceUntilIdle() + val node = viewModel.uiState.first().graph.nodes.find { it.id == nodeId }!! + assertTrue(node.data is NodeData.ColorFunction) + } + + @Test + fun addTextureLayerNode_addsNode() = testScope.runTest { + val nodeId = viewModel.addTextureLayerNode() + testScope.advanceUntilIdle() + val node = viewModel.uiState.first().graph.nodes.find { it.id == nodeId }!! + assertTrue(node.data is NodeData.TextureLayer) + } + + @Test + fun addBehaviorNode_addsNode() = testScope.runTest { + val nodeId = viewModel.addBehaviorNode() + testScope.advanceUntilIdle() + val node = viewModel.uiState.first().graph.nodes.find { it.id == nodeId }!! + assertTrue(node.data is NodeData.Behavior) + } + + @Test + fun deleteEdge_updatesState() = testScope.runTest { + val node1 = "node1" + val node2 = "node2" + val edge = com.example.cahier.developer.brushgraph.data.GraphEdge(fromNodeId = node1, toNodeId = node2, toPortId = "tip") + repository.setGraph(BrushGraph( + nodes = listOf( + GraphNode(id = node1, data = NodeData.Tip(ProtoBrushTip.getDefaultInstance())), + GraphNode(id = node2, data = NodeData.Coat()) + ), + edges = listOf(edge) + )) + + testScope.advanceUntilIdle() + + viewModel.onEdgeClick(edge) + assertEquals(edge, viewModel.uiState.first().selectedEdge) + + viewModel.deleteEdge(edge) + testScope.advanceUntilIdle() + + val state = viewModel.uiState.first() + assertNull(state.selectedEdge) + assertFalse(state.graph.edges.contains(edge)) + } + + @Test + fun finalizeEdgeEdit_updatesState() = testScope.runTest { + val node1 = "node1" + val node2 = "node2" + val node3 = "node3" + val oldEdge = com.example.cahier.developer.brushgraph.data.GraphEdge(fromNodeId = node1, toNodeId = node2, toPortId = "tip") + repository.setGraph(BrushGraph( + nodes = listOf( + GraphNode(id = node1, data = NodeData.Tip(ProtoBrushTip.getDefaultInstance())), + GraphNode(id = node2, data = NodeData.Coat()), + GraphNode(id = node3, data = NodeData.Coat(paintPortIds = listOf("color"))) + ), + edges = listOf(oldEdge) + )) + + testScope.advanceUntilIdle() + + viewModel.detachEdge(oldEdge) + assertEquals(oldEdge, viewModel.uiState.first().detachedEdge) + + viewModel.finalizeEdgeEdit(oldEdge, node1, node3, "color") + testScope.advanceUntilIdle() + + val state = viewModel.uiState.first() + assertNull(state.detachedEdge) + assertTrue(state.graph.edges.any { it.fromNodeId == node1 && it.toNodeId == node3 && it.toPortId == "color" }) + } + + @Test + fun detachEdge_updatesState() = testScope.runTest { + val edge = com.example.cahier.developer.brushgraph.data.GraphEdge(fromNodeId = "node1", toNodeId = "node2", toPortId = "tip") + + viewModel.detachEdge(edge) + + val state = viewModel.uiState.first() + assertEquals(edge, state.detachedEdge) + } + + @Test + fun addNodeAndConnect_addsNodeAndEdge() = testScope.runTest { + val nodeData = NodeData.Tip(ProtoBrushTip.getDefaultInstance()) + val targetNodeId = "node2" + val targetPortId = "tip" + + repository.setGraph(BrushGraph(nodes = listOf(GraphNode(id = targetNodeId, data = NodeData.Coat(tipPortId = targetPortId))))) + testScope.advanceUntilIdle() + + val newNodeId = viewModel.addNodeAndConnect(nodeData, targetNodeId, targetPortId) + testScope.advanceUntilIdle() + + val state = viewModel.uiState.first() + assertTrue(state.graph.nodes.any { it.id == newNodeId }) + assertTrue(state.graph.edges.any { it.fromNodeId == newNodeId && it.toNodeId == targetNodeId && it.toPortId == targetPortId }) + } + + @Test + fun clearStrokes_clearsList() = testScope.runTest { + viewModel.strokeList.add(mock(androidx.ink.strokes.Stroke::class.java)) + assertFalse(viewModel.strokeList.isEmpty()) + + viewModel.clearStrokes() + assertTrue(viewModel.strokeList.isEmpty()) + } + + @Test + fun getBrushColor_returnsColor() = testScope.runTest { + val color = 0xFF00FF00.toInt() + viewModel.updateTestBrushColor(color) + + testScope.advanceUntilIdle() + + assertEquals(color, viewModel.getBrushColor()) + } + + @Test + fun updateAllTextureIds_updatesState() = testScope.runTest { + val ids = setOf("tex1", "tex2") + org.mockito.Mockito.`when`(mockTextureStore.getAllIds()).thenReturn(ids) + + viewModel.updateAllTextureIds() + + val state = viewModel.uiState.first() + assertEquals(ids, state.allTextureIds) + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphUiState.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphUiState.kt new file mode 100644 index 0000000..cb9aed3 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphUiState.kt @@ -0,0 +1,28 @@ +package com.example.cahier.developer.brushgraph.viewmodel + +import com.example.cahier.developer.brushgraph.data.BrushGraph +import com.example.cahier.developer.brushgraph.data.GraphEdge +import com.example.cahier.developer.brushgraph.data.GraphPoint +import com.example.cahier.developer.brushgraph.data.GraphValidationException + +data class BrushGraphUiState( + val graph: BrushGraph = BrushGraph(), + val isSelectionMode: Boolean = false, + val selectedNodeIds: Set = emptySet(), + val activeEdgeSourceId: String? = null, + val selectedEdge: GraphEdge? = null, + val testAutoUpdateStrokes: Boolean = true, + val testBrushColor: Int? = null, + val testBrushSize: Float = 10f, + val isErrorPaneOpen: Boolean = false, + val zoom: Float = 1f, + val offset: GraphPoint = GraphPoint(0f, 0f), + val textFieldsLocked: Boolean = false, + val selectedNodeId: String? = null, + val focusTrigger: Int = 0, + val detachedEdge: GraphEdge? = null, + val isPreviewExpanded: Boolean = true, + val isDarkCanvas: Boolean = false, + val graphIssues: List = emptyList(), + val allTextureIds: Set = emptySet() +) \ No newline at end of file diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModel.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModel.kt index c58f519..b3814d9 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModel.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModel.kt @@ -13,8 +13,6 @@ * * See the License for the specific language governing permissions and * * limitations under the License. */ -@file:OptIn(androidx.ink.brush.ExperimentalInkCustomBrushApi::class, kotlinx.coroutines.FlowPreview::class) - package com.example.cahier.developer.brushgraph.viewmodel import androidx.compose.runtime.getValue @@ -74,28 +72,6 @@ import com.example.cahier.developer.brushgraph.data.TutorialAction import java.io.ByteArrayOutputStream import java.io.ByteArrayInputStream -data class BrushGraphUiState( - val graph: BrushGraph = BrushGraph(), - val isSelectionMode: Boolean = false, - val selectedNodeIds: Set = emptySet(), - val activeEdgeSourceId: String? = null, - val selectedEdge: GraphEdge? = null, - val testAutoUpdateStrokes: Boolean = true, - val testBrushColor: Int? = null, - val testBrushSize: Float = 10f, - val isErrorPaneOpen: Boolean = false, - val zoom: Float = 1f, - val offset: GraphPoint = GraphPoint(0f, 0f), - val textFieldsLocked: Boolean = false, - val selectedNodeId: String? = null, - val focusTrigger: Int = 0, - val detachedEdge: GraphEdge? = null, - val isPreviewExpanded: Boolean = true, - val isDarkCanvas: Boolean = false, - val graphIssues: List = emptyList(), - val allTextureIds: Set = emptySet() -) - /** ViewModel to manage the state of the brush graph. */ @HiltViewModel class BrushGraphViewModel @Inject constructor( From 282d91df97a866124d7866c9016058c4be2e437b Mon Sep 17 00:00:00 2001 From: Maxwell Metzger Mitchell <60010512+maxmmitchell@users.noreply.github.com> Date: Tue, 5 May 2026 20:08:29 +0000 Subject: [PATCH 7/7] Respond to other comments from Chris --- .../viewmodel/BrushGraphViewModelTest.kt | 4 +-- .../brushgraph/viewmodel/BrushGraphUiState.kt | 3 +- .../viewmodel/BrushGraphViewModel.kt | 30 ++++++++++++------- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/app/src/androidTest/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModelTest.kt b/app/src/androidTest/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModelTest.kt index 3a529be..8a74afd 100644 --- a/app/src/androidTest/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModelTest.kt +++ b/app/src/androidTest/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModelTest.kt @@ -225,7 +225,7 @@ class BrushGraphViewModelTest { @Test fun updateTestBrushColor_updatesState() = testScope.runTest { - val color = 0xFF00FF00.toInt() + val color = androidx.compose.ui.graphics.Color.Green viewModel.updateTestBrushColor(color) assertEquals(color, viewModel.uiState.first().testBrushColor) } @@ -523,7 +523,7 @@ class BrushGraphViewModelTest { @Test fun getBrushColor_returnsColor() = testScope.runTest { - val color = 0xFF00FF00.toInt() + val color = androidx.compose.ui.graphics.Color.Green viewModel.updateTestBrushColor(color) testScope.advanceUntilIdle() diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphUiState.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphUiState.kt index cb9aed3..7d23b24 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphUiState.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphUiState.kt @@ -1,5 +1,6 @@ package com.example.cahier.developer.brushgraph.viewmodel +import androidx.compose.ui.graphics.Color import com.example.cahier.developer.brushgraph.data.BrushGraph import com.example.cahier.developer.brushgraph.data.GraphEdge import com.example.cahier.developer.brushgraph.data.GraphPoint @@ -12,7 +13,7 @@ data class BrushGraphUiState( val activeEdgeSourceId: String? = null, val selectedEdge: GraphEdge? = null, val testAutoUpdateStrokes: Boolean = true, - val testBrushColor: Int? = null, + val testBrushColor: Color? = null, val testBrushSize: Float = 10f, val isErrorPaneOpen: Boolean = false, val zoom: Float = 1f, diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModel.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModel.kt index b3814d9..f79eb6e 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModel.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModel.kt @@ -19,14 +19,18 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color import androidx.ink.brush.Brush import androidx.ink.brush.BrushFamily import androidx.ink.brush.StockBrushes import androidx.ink.brush.TextureBitmapStore +import androidx.ink.brush.compose.composeColor +import androidx.ink.brush.compose.createWithComposeColor import androidx.ink.storage.AndroidBrushFamilySerialization import androidx.ink.storage.BrushFamilyDecodeCallback import androidx.ink.strokes.Stroke import com.example.cahier.developer.brushdesigner.data.CustomBrushEntity +import androidx.annotation.VisibleForTesting import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.cahier.core.ui.CahierTextureBitmapStore @@ -92,34 +96,35 @@ class BrushGraphViewModel @Inject constructor( private val _uiState = MutableStateFlow(BrushGraphUiState()) val uiState: StateFlow = _uiState.asStateFlow() - val brush: StateFlow = uiState + val brush: StateFlow = uiState .map { Triple(it.graph, it.testBrushColor, it.testBrushSize) } .distinctUntilChanged() .map { (graph, testBrushColor, testBrushSize) -> val family = repository.getBrushFamily() - val color = testBrushColor ?: 0 + val color = testBrushColor ?: Color.Black val size = testBrushSize if (family != null) { - Brush.createWithColorIntArgb(family, color, size, 0.1f) + Brush.createWithComposeColor(family, color, size, 0.1f) } else { - Brush.createWithColorIntArgb(StockBrushes.marker(), color, size, 0.1f) + Brush.createWithComposeColor(StockBrushes.marker(), color, size, 0.1f) } }.stateIn( scope = viewModelScope, started = SharingStarted.Eagerly, - initialValue = Brush.createWithColorIntArgb( + initialValue = Brush.createWithComposeColor( StockBrushes.marker(), - 0, + Color.Black, size = 20f, epsilon = 0.1f, ) ) /** The list of strokes drawn in the preview area. */ + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) val strokeList = mutableStateListOf() - fun updateTestBrushColor(colorArgb: Int) { - _uiState.update { it.copy(testBrushColor = colorArgb) } + fun updateTestBrushColor(color: Color) { + _uiState.update { it.copy(testBrushColor = color) } } fun updateTestBrushSize(size: Float) { @@ -130,10 +135,15 @@ class BrushGraphViewModel @Inject constructor( _uiState.update { state -> state.copy(allTextureIds = textureStore.getAllIds()) } } - val tutorialManager = TutorialManager(repository) + private val tutorialManager = TutorialManager(repository) + // Read-only for UI val tutorialStep get() = tutorialManager.tutorialStep + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) val currentStepIndex get() = tutorialManager.currentStepIndex + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) val isTutorialSandboxMode get() = tutorialManager.isTutorialSandboxMode fun startTutorial() { @@ -520,7 +530,7 @@ class BrushGraphViewModel @Inject constructor( repository.loadBrushFamily(family) } - fun getBrushColor(): Int = brush.value.colorIntArgb + fun getBrushColor(): Color = brush.value.composeColor fun updateZoom(newZoom: Float) { _uiState.update { state -> state.copy(zoom = newZoom) }