From 5551ca2836ae4fe5595e54b18f3085bd2997b2d8 Mon Sep 17 00:00:00 2001 From: Maxwell Metzger Mitchell <60010512+maxmmitchell@users.noreply.github.com> Date: Mon, 27 Apr 2026 06:55:00 +0000 Subject: [PATCH 1/5] Add main screens and integrate brushgraph into the app --- .../example/cahier/CahierListDetailTest.kt | 1 + .../cahier/core/navigation/CahierNavGraph.kt | 13 + .../cahier/core/ui/ColorPickerDialog.kt | 3 +- .../brushgraph/ui/BrushGraphContent.kt | 100 +++ .../brushgraph/ui/BrushGraphMenus.kt | 474 +++++++++++++ .../brushgraph/ui/BrushGraphScreen.kt | 627 ++++++++++++++++++ .../developer/brushgraph/ui/Notification.kt | 294 ++++++++ .../brushgraph/ui/TutorialOverlay.kt | 117 ++++ .../brushgraph/ui/TutorialOverlayHost.kt | 138 ++++ .../cahier/features/drawing/DrawingCanvas.kt | 6 +- .../cahier/features/drawing/DrawingToolbox.kt | 30 +- .../features/drawing/HorizontalToolbox.kt | 2 + .../features/drawing/VerticalToolbox.kt | 2 + .../viewmodel/DrawingCanvasViewModel.kt | 24 + .../cahier/features/home/CahierHomeScreen.kt | 4 + .../cahier/features/home/SettingsScreen.kt | 16 +- .../home/viewmodel/SettingsViewModel.kt | 7 + app/src/main/res/values/strings.xml | 6 +- .../java/com/example/cahier/ScreenshotTest.kt | 2 + 19 files changed, 1854 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphContent.kt create mode 100644 app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphMenus.kt create mode 100644 app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt create mode 100644 app/src/main/java/com/example/cahier/developer/brushgraph/ui/Notification.kt create mode 100644 app/src/main/java/com/example/cahier/developer/brushgraph/ui/TutorialOverlay.kt create mode 100644 app/src/main/java/com/example/cahier/developer/brushgraph/ui/TutorialOverlayHost.kt diff --git a/app/src/androidTest/java/com/example/cahier/CahierListDetailTest.kt b/app/src/androidTest/java/com/example/cahier/CahierListDetailTest.kt index 96e6369..13fc9df 100644 --- a/app/src/androidTest/java/com/example/cahier/CahierListDetailTest.kt +++ b/app/src/androidTest/java/com/example/cahier/CahierListDetailTest.kt @@ -83,6 +83,7 @@ class CahierListDetailTest { HomePane( navigateToCanvas = { _ -> }, navigateToDrawingCanvas = { _ -> }, + navigateToBrushGraph = {}, navigateUp = {}, forceCompact = forceCompact, homeScreenViewModel = fakeViewModel diff --git a/app/src/main/java/com/example/cahier/core/navigation/CahierNavGraph.kt b/app/src/main/java/com/example/cahier/core/navigation/CahierNavGraph.kt index 1bfe61a..e96ae74 100644 --- a/app/src/main/java/com/example/cahier/core/navigation/CahierNavGraph.kt +++ b/app/src/main/java/com/example/cahier/core/navigation/CahierNavGraph.kt @@ -56,6 +56,9 @@ fun CahierNavHost( navigateToBrushDesigner = { navController.navigate(BrushDesignerDestination.route) }, + navigateToBrushGraph = { + navController.navigate(BrushGraphDestination.route) + }, ) } @@ -77,10 +80,16 @@ fun CahierNavHost( ) { navBackStackEntry -> DrawingCanvas( navigateUp = { navController.navigateUp() }, + navigateToBrushGraph = { navController.navigate(BrushGraphDestination.route) } ) } composable(route = BrushDesignerDestination.route) { BrushDesignerScreen( + onNavigateUp = { navController.navigateUp() }, + ) + } + composable(route = BrushGraphDestination.route) { + com.example.cahier.developer.brushgraph.ui.BrushGraphScreen( onNavigateUp = { navController.navigateUp() } ) } @@ -104,3 +113,7 @@ object DrawingCanvasDestination : NavigationDestination { object BrushDesignerDestination : NavigationDestination { override val route = "brush_designer" } + +object BrushGraphDestination : NavigationDestination { + override val route = "brush_graph" +} \ No newline at end of file diff --git a/app/src/main/java/com/example/cahier/core/ui/ColorPickerDialog.kt b/app/src/main/java/com/example/cahier/core/ui/ColorPickerDialog.kt index 801c1be..50c6fc3 100644 --- a/app/src/main/java/com/example/cahier/core/ui/ColorPickerDialog.kt +++ b/app/src/main/java/com/example/cahier/core/ui/ColorPickerDialog.kt @@ -147,4 +147,5 @@ private fun ColorPickerContentPreview() { onColorSelected = {}, onDismissRequest = {} ) -} \ No newline at end of file +} + diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphContent.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphContent.kt new file mode 100644 index 0000000..3b28217 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphContent.kt @@ -0,0 +1,100 @@ +/* + * * 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.core.animateDpAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.toSize + +@Composable +fun BrushGraphContent( + isLandscape: Boolean, + isNodeSelected: Boolean, + isEdgeSelected: Boolean, + isErrorPaneOpen: Boolean, + isPreviewExpanded: Boolean, + viewportSize: Size, + onViewportSizeChange: (Size) -> Unit, + canvasSlot: @Composable (trashPaddingBottom: Dp) -> Unit, + inspectorSlot: @Composable () -> Unit, + notificationPaneSlot: @Composable () -> Unit, + notificationIconSlot: @Composable (indicatorPaddingEnd: Dp) -> Unit, + previewSlot: @Composable () -> Unit, + menuSlot: @Composable () -> Unit, + fabSlot: @Composable (viewportSize: Size) -> Unit, + tutorialSlot: @Composable (viewportSize: Size) -> Unit, + dialogSlot: @Composable () -> Unit, + modifier: Modifier = Modifier, +) { + dialogSlot() + + BoxWithConstraints(modifier = modifier.fillMaxSize()) { + val currentIsLandscape = maxWidth > maxHeight + + val isSidePaneOpen = currentIsLandscape && (isNodeSelected || isEdgeSelected || isErrorPaneOpen) + val indicatorPaddingEnd by animateDpAsState( + targetValue = if (isSidePaneOpen) (INSPECTOR_WIDTH_LANDSCAPE + 16).dp else 16.dp, + label = "indicatorPaddingEnd", + ) + val previewHeight = if (isPreviewExpanded) { + PREVIEW_HEIGHT_EXPANDED + } else { + PREVIEW_HEIGHT_COLLAPSED + } + val isAnySidePaneOpen = isNodeSelected || isEdgeSelected || isErrorPaneOpen + + val trashPaddingBottom by animateDpAsState( + targetValue = + if (!currentIsLandscape && isAnySidePaneOpen) { + (maxOf(previewHeight, INSPECTOR_HEIGHT_PORTRAIT) + 16).dp + } else { + (previewHeight + 16).dp + }, + label = "trashPaddingBottom", + ) + + Scaffold { paddingValues -> + Box( + modifier = + Modifier.fillMaxSize().padding(paddingValues).onGloballyPositioned { coordinates -> + onViewportSizeChange(coordinates.size.toSize()) + } + ) { + canvasSlot(trashPaddingBottom) + inspectorSlot() + notificationPaneSlot() + notificationIconSlot(indicatorPaddingEnd) + previewSlot() + menuSlot() + fabSlot(viewportSize) + tutorialSlot(viewportSize) + } + } + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphMenus.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphMenus.kt new file mode 100644 index 0000000..bf1a48f --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphMenus.kt @@ -0,0 +1,474 @@ +/* + * * 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.animation.AnimatedVisibility +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Arrangement +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.example.cahier.R +import com.example.cahier.developer.brushgraph.data.TutorialStep +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import androidx.ink.brush.BrushFamily +import androidx.ink.brush.StockBrushes +import androidx.ink.brush.TextureBitmapStore +import com.example.cahier.developer.brushdesigner.data.CustomBrushEntity +import com.example.cahier.features.drawing.CustomBrushes + +@Composable +fun MoreOptionsMenu( + expanded: Boolean, + onDismiss: () -> Unit, + isTutorialSandboxMode: Boolean, + onSelectMode: () -> Unit, + onTutorialAction: () -> Unit, + onExport: () -> Unit, + onImport: () -> Unit, + onOrganize: () -> Unit, + showTemplatesMenu: Boolean, + onShowTemplatesMenuChange: (Boolean) -> Unit, + onTemplateSelect: (BrushFamily) -> Unit, + customBrushes: List>, + onCustomBrushSelect: (BrushFamily) -> Unit, + onDeleteBrush: () -> Unit, + onOptions: () -> Unit +) { + val uriHandler = LocalUriHandler.current + DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { + DropdownMenuItem( + text = { Text(stringResource(R.string.bg_select)) }, + onClick = onSelectMode + ) + DropdownMenuItem( + text = { Text(if (isTutorialSandboxMode) stringResource(R.string.bg_menu_exit_tutorial) else stringResource(R.string.bg_menu_tutorial)) }, + onClick = onTutorialAction + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.bg_export)) }, + onClick = onExport + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.bg_import)) }, + onClick = onImport + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.bg_organize)) }, + onClick = onOrganize + ) + Box { + DropdownMenuItem( + text = { Text(stringResource(R.string.bg_templates)) }, + onClick = { onShowTemplatesMenuChange(true) }, + trailingIcon = { Icon(Icons.Default.ChevronRight, contentDescription = null) } + ) + DropdownMenu( + expanded = showTemplatesMenu, + onDismissRequest = { onShowTemplatesMenuChange(false) }, + offset = DpOffset(x = 127.dp, y = (-56).dp) + ) { + listOf( + R.string.bg_pressure_pen to StockBrushes.pressurePen(), + R.string.marker to StockBrushes.marker(), + R.string.highlighter to StockBrushes.highlighter(), + R.string.dashed_line to StockBrushes.dashedLine() + ).forEach { (title, brush) -> + DropdownMenuItem( + text = { Text(stringResource(title)) }, + onClick = { + onTemplateSelect(brush) + onShowTemplatesMenuChange(false) + } + ) + } + + if (customBrushes.isNotEmpty()) { + HorizontalDivider() + Text( + text = stringResource(R.string.bg_menu_custom_brushes), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) + customBrushes.forEach { (name, family) -> + DropdownMenuItem( + text = { Text(name) }, + onClick = { + onCustomBrushSelect(family) + onShowTemplatesMenuChange(false) + } + ) + } + } + } + } + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + DropdownMenuItem( + text = { Text(stringResource(R.string.bg_delete_brush), color = MaterialTheme.colorScheme.error) }, + onClick = onDeleteBrush + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.bg_options)) }, + onClick = onOptions + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.bg_menu_feedback)) }, + leadingIcon = { Icon(painterResource(R.drawable.outline_open_in_new_24), contentDescription = null) }, + onClick = { + onDismiss() + uriHandler.openUri("https://github.com/android/cahier/issues") + } + ) + } +} + +@Composable +fun PaletteMenu( + expanded: Boolean, + onDismiss: () -> Unit, + savedBrushes: List, + onBrushSelect: (CustomBrushEntity) -> Unit, + onBrushDelete: (CustomBrushEntity) -> Unit +) { + DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { + if (savedBrushes.isEmpty()) { + DropdownMenuItem( + text = { Text(stringResource(R.string.bg_no_saved_brushes)) }, + onClick = onDismiss, + ) + } else { + savedBrushes.forEach { entity -> + DropdownMenuItem( + text = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(entity.name, modifier = Modifier.weight(1f)) + IconButton(onClick = { onBrushDelete(entity) }) { + Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.bg_cd_delete)) + } + } + }, + onClick = { onBrushSelect(entity) } + ) + } + } + } +} + +@Composable +fun CreateNodeSpeedDial( + isLandscape: Boolean, + isAnySidePaneOpen: Boolean, + isPreviewExpanded: Boolean, + viewportSize: androidx.compose.ui.geometry.Size, + modifier: Modifier = Modifier, + menuContent: @Composable (onClose: () -> Unit) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + + val previewHeight = if (isPreviewExpanded) { + PREVIEW_HEIGHT_EXPANDED + } else { + PREVIEW_HEIGHT_COLLAPSED + } + + val fabPaddingBottom by + animateDpAsState( + targetValue = + if (!isLandscape && isAnySidePaneOpen) { + (maxOf(previewHeight, INSPECTOR_HEIGHT_PORTRAIT) + 16).dp + } else { + (previewHeight + 16).dp + }, + label = "fabPaddingBottom", + ) + + val fabPaddingEnd by + animateDpAsState( + targetValue = + if (isLandscape && isAnySidePaneOpen) { + (INSPECTOR_WIDTH_LANDSCAPE + 16).dp + } else { + 16.dp + }, + label = "fabPaddingEnd", + ) + + Box(modifier = modifier.padding(bottom = fabPaddingBottom, end = fabPaddingEnd).zIndex(2f)) { + Column(horizontalAlignment = Alignment.End) { + AnimatedVisibility( + visible = expanded, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + Surface( + modifier = Modifier.padding(bottom = 8.dp).width(180.dp), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 4.dp, + shadowElevation = 8.dp, + ) { + Column(modifier = Modifier.padding(vertical = 8.dp)) { + menuContent { expanded = false } + } + } + } + FloatingActionButton( + onClick = { expanded = !expanded }, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ) { + Icon( + if (expanded) Icons.Default.Close else Icons.Default.Add, + contentDescription = stringResource(R.string.bg_cd_create_node), + ) + } + } + } +} + +@Composable +fun GraphActionMenu( + onClose: () -> Unit, + onExport: () -> Unit, + onLoadBrushFile: () -> Unit, + onSaveToPalette: () -> Unit, + textureStore: TextureBitmapStore, + onOrganize: () -> Unit, + onDeleteBrush: () -> Unit, + onTutorialExitRequested: () -> Unit, + savedBrushes: List, + tutorialStep: TutorialStep?, + isTutorialSandboxMode: Boolean, + onEnterSelectionMode: () -> Unit, + onLoadBrushFamily: (BrushFamily) -> Unit, + onLoadFromPalette: (CustomBrushEntity) -> Unit, + onDeleteFromPalette: (String) -> Unit, + onStartTutorialSandbox: () -> Unit, + textFieldsLocked: Boolean, + onToggleTextFieldsLocked: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + var showMoreMenu by remember { mutableStateOf(false) } + var showPaletteMenu by remember { mutableStateOf(false) } + var showClearConfirmation by remember { mutableStateOf(false) } + var showReorganizeConfirmation by remember { mutableStateOf(false) } + var showTemplatesMenu by remember { mutableStateOf(false) } + var showOptionsDialog by remember { mutableStateOf(false) } + var showTutorialWarningDialog by remember { mutableStateOf(false) } + + + ClearGraphConfirmationDialog( + show = showClearConfirmation, + onDismiss = { showClearConfirmation = false }, + onConfirm = { + onDeleteBrush() + showClearConfirmation = false + } + ) + + LaunchedEffect(tutorialStep) { + if (isTutorialSandboxMode && tutorialStep == null) { + onTutorialExitRequested() + } + } + + TutorialWarningDialog( + show = showTutorialWarningDialog, + onDismiss = { showTutorialWarningDialog = false }, + onConfirm = { + onStartTutorialSandbox() + showTutorialWarningDialog = false + } + ) + + OptionsDialog( + show = showOptionsDialog, + onDismiss = { showOptionsDialog = false }, + textFieldsLocked = textFieldsLocked, + onToggleTextFieldsLocked = onToggleTextFieldsLocked + ) + + ReorganizeConfirmationDialog( + show = showReorganizeConfirmation, + onDismiss = { showReorganizeConfirmation = false }, + onConfirm = { + onOrganize() + showReorganizeConfirmation = false + } + ) + + Surface( + modifier = modifier, + shape = RoundedCornerShape(32.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), + tonalElevation = 4.dp, + shadowElevation = 8.dp, + ) { + Row(modifier = Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) { + IconButton( + onClick = onClose, + colors = IconButtonDefaults.iconButtonColors(containerColor = Color.Transparent), + ) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.bg_cd_exit)) + } + + VerticalDivider( + modifier = Modifier.height(24.dp).padding(horizontal = 4.dp), + thickness = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant, + ) + + Box { + IconButton(onClick = { showMoreMenu = true }) { + Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.bg_cd_more_options)) + } + + MoreOptionsMenu( + expanded = showMoreMenu, + onDismiss = { showMoreMenu = false }, + isTutorialSandboxMode = isTutorialSandboxMode, + onSelectMode = { + onEnterSelectionMode() + showTemplatesMenu = false + showMoreMenu = false + }, + onTutorialAction = { + showMoreMenu = false + if (isTutorialSandboxMode) { + onTutorialExitRequested() + } else { + showTutorialWarningDialog = true + } + }, + onExport = { + showMoreMenu = false + onExport() + }, + onImport = { + showMoreMenu = false + onLoadBrushFile() + }, + onOrganize = { + showMoreMenu = false + showReorganizeConfirmation = true + }, + showTemplatesMenu = showTemplatesMenu, + onShowTemplatesMenuChange = { showTemplatesMenu = it }, + onTemplateSelect = { family -> + onLoadBrushFamily(family) + showTemplatesMenu = false + showMoreMenu = false + }, + customBrushes = CustomBrushes.getBrushes(context).map { it.name to it.brushFamily }, + onCustomBrushSelect = { family -> + onLoadBrushFamily(family) + showTemplatesMenu = false + showMoreMenu = false + }, + onDeleteBrush = { + showMoreMenu = false + showClearConfirmation = true + }, + onOptions = { + showMoreMenu = false + showOptionsDialog = true + } + ) + } + + Box { + TextButton(onClick = { showPaletteMenu = true }) { + Text(stringResource(R.string.bg_my_palette)) + } + + PaletteMenu( + expanded = showPaletteMenu, + onDismiss = { showPaletteMenu = false }, + savedBrushes = savedBrushes, + onBrushSelect = { entity -> + onLoadFromPalette(entity) + showPaletteMenu = false + }, + onBrushDelete = { entity -> + onDeleteFromPalette(entity.name) + } + ) + } + + Spacer(Modifier.width(8.dp)) + + Button( + onClick = onSaveToPalette, + shape = RoundedCornerShape(16.dp), + modifier = Modifier.height(40.dp), + ) { + Text(stringResource(R.string.bg_save_to_palette)) + } + } + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt new file mode 100644 index 0000000..77ff6b4 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt @@ -0,0 +1,627 @@ +/* + * * 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.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.util.Log +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.geometry.Size +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Layers +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.Psychology +import androidx.compose.material.icons.filled.ShapeLine +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +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.lifecycle.compose.collectAsStateWithLifecycle +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import androidx.compose.ui.res.stringResource +import com.example.cahier.R +import com.example.cahier.developer.brushgraph.ui.node.NodeRegistry +import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer +import androidx.ink.storage.AndroidBrushFamilySerialization +import androidx.ink.storage.BrushFamilyDecodeCallback +import androidx.hilt.navigation.compose.hiltViewModel +import com.example.cahier.developer.brushdesigner.ui.CustomColorPickerDialog +import com.example.cahier.developer.brushgraph.viewmodel.BrushGraphViewModel +import com.example.cahier.developer.brushgraph.ui.EdgeInspector +import com.example.cahier.developer.brushgraph.ui.NodeInspector +import com.example.cahier.developer.brushgraph.ui.AdaptiveInspectorPane +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.GraphValidationException +import com.example.cahier.developer.brushgraph.ui.INSPECTOR_HEIGHT_PORTRAIT +import com.example.cahier.developer.brushgraph.ui.INSPECTOR_WIDTH_LANDSCAPE +import com.example.cahier.developer.brushgraph.data.DisplayText +import com.example.cahier.developer.brushgraph.data.NodeData +import com.example.cahier.developer.brushgraph.data.getVisiblePorts +import com.example.cahier.developer.brushgraph.ui.getTooltip +import com.example.cahier.developer.brushgraph.data.inferNodeData +import com.example.cahier.developer.brushgraph.ui.PREVIEW_HEIGHT_COLLAPSED +import kotlinx.coroutines.launch +import com.example.cahier.core.ui.theme.CahierAppTheme +import com.example.cahier.developer.brushgraph.ui.PREVIEW_HEIGHT_EXPANDED +import com.example.cahier.developer.brushgraph.data.TutorialAction +import com.example.cahier.core.ui.CahierTextureBitmapStore + +/** The main UI for the Brush Graph studio. */ +@Composable +fun BrushGraphScreen( + onNavigateUp: () -> Unit, + modifier: Modifier = Modifier +) { + val viewModel: BrushGraphViewModel = hiltViewModel() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + val scope = rememberCoroutineScope() + + // Use hoisted CahierTextureBitmapStore from ViewModel + val textureStore = viewModel.textureStore + + val renderer = remember { CanvasStrokeRenderer.create(textureStore) } + + val primaryColor = MaterialTheme.colorScheme.primary + val onSurfaceColor = MaterialTheme.colorScheme.onSurface + LaunchedEffect(primaryColor) { + viewModel.updateTestBrushColor(primaryColor.toArgb()) + } + + var showColorPicker by remember { mutableStateOf(false) } + var colorPickerInitialColor by remember { mutableStateOf(onSurfaceColor) } + var colorPickerOnColorSelected by remember { mutableStateOf({ _: Color -> }) } + + if (showColorPicker) { + CustomColorPickerDialog( + initialColor = colorPickerInitialColor, + onColorSelected = colorPickerOnColorSelected, + onDismissRequest = { showColorPicker = false } + ) + } + + // Texture picking logic + var showTextureNameDialog by remember { mutableStateOf(false) } + var pendingTextureUri by remember { mutableStateOf(null) } + var textureNameInput by remember { mutableStateOf("") } + + val texturePickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri: Uri? -> + if (uri != null) { + pendingTextureUri = uri + showTextureNameDialog = true + } + } + + NameTextureDialog( + show = showTextureNameDialog, + onDismiss = { showTextureNameDialog = false }, + textureNameInput = textureNameInput, + onTextureNameInputChange = { textureNameInput = it }, + onConfirm = { + if (textureNameInput.isNotBlank() && pendingTextureUri != null) { + val uri = pendingTextureUri!! + val name = textureNameInput + scope.launch { + val bitmap = context.contentResolver.openInputStream(uri)?.use { + BitmapFactory.decodeStream(it) + } + if (bitmap != null) { + textureStore.loadTexture(name, bitmap) + viewModel.updateAllTextureIds() + } + showTextureNameDialog = false + textureNameInput = "" + } + } + } + ) + + val brushFilePickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri: Uri? -> + uri?.let { + scope.launch { + try { + val family = context.contentResolver.openInputStream(it)?.use { stream -> + try { + AndroidBrushFamilySerialization.decode( + stream, + BrushFamilyDecodeCallback { id: String, bitmap: Bitmap? -> + if (bitmap != null) { + textureStore.loadTexture(id, bitmap) + viewModel.updateAllTextureIds() + } + id + } + ) + } catch (e: Exception) { + Log.d("BrushGraphWidget", "Failed to decode with AndroidBrushFamilySerialization, trying legacy fallback") + null + } + } + + if (family == null) { + Log.d("BrushGraphWidget", "Failed to decode with AndroidBrushFamilySerialization, and legacy fallback is disabled.") + viewModel.postDebug(DisplayText.Resource(R.string.bg_err_load_brush_legacy_unsupported)) + } else { + viewModel.loadBrushFamily(family) + viewModel.postDebug(DisplayText.Resource(R.string.bg_msg_brush_loaded_success)) + } + } catch (e: Exception) { + android.util.Log.e("BrushGraphWidget", "Failed to load brush", e) + viewModel.postDebug(DisplayText.Resource(R.string.bg_err_load_brush_failed_with_msg, listOf(e.message ?: ""))) + } + } + } + } + + val brushExportLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("application/octet-stream") + ) { uri: Uri? -> + uri?.let { + scope.launch { + try { + context.contentResolver.openOutputStream(it)?.use { outputStream -> + AndroidBrushFamilySerialization.encode( + viewModel.brush.value.family, + outputStream, + textureStore + ) + } + viewModel.postDebug(DisplayText.Resource(R.string.bg_msg_brush_exported_success)) + } catch (e: Exception) { + android.util.Log.e("BrushGraphWidget", "Failed to export brush", e) + viewModel.postDebug(DisplayText.Resource(R.string.bg_err_export_brush_failed_with_msg, listOf(e.message ?: ""))) + } + } + } + } + + // Save to palette logic + var showSavePaletteDialog by remember { mutableStateOf(false) } + var paletteBrushNameInput by remember { mutableStateOf("") } + + SaveToPaletteDialog( + show = showSavePaletteDialog, + onDismiss = { showSavePaletteDialog = false }, + paletteBrushNameInput = paletteBrushNameInput, + onPaletteBrushNameInputChange = { paletteBrushNameInput = it }, + onConfirm = { + if (paletteBrushNameInput.isNotBlank()) { + viewModel.saveToPalette(paletteBrushNameInput, textureStore) + showSavePaletteDialog = false + paletteBrushNameInput = "" + } + } + ) + + CahierAppTheme { + BoxWithConstraints(modifier = modifier.fillMaxSize()) { + val isLandscape = maxWidth > maxHeight + var viewportSize by remember { mutableStateOf(androidx.compose.ui.geometry.Size.Zero) } + var showTutorialFinishDialog by remember { mutableStateOf(false) } + + val isSidePaneOpen = isLandscape && (uiState.selectedNodeId != null || uiState.isErrorPaneOpen) + val indicatorPaddingEnd by animateDpAsState( + targetValue = if (isSidePaneOpen) (INSPECTOR_WIDTH_LANDSCAPE + 16).dp else 16.dp, + label = "indicatorPaddingEnd", + ) + val previewHeight = if (uiState.isPreviewExpanded) { + PREVIEW_HEIGHT_EXPANDED + } else { + PREVIEW_HEIGHT_COLLAPSED + } + val isNodeSelected = uiState.selectedNodeId != null + val isEdgeSelected = uiState.selectedEdge != null + val isErrorPaneOpen = uiState.isErrorPaneOpen + val isAnySidePaneOpen = isNodeSelected || isEdgeSelected || isErrorPaneOpen + + val trashPaddingBottom by animateDpAsState( + targetValue = + if (!isLandscape && isAnySidePaneOpen) { + (maxOf(previewHeight, INSPECTOR_HEIGHT_PORTRAIT) + 16).dp + } else { + (previewHeight + 16).dp + }, + label = "trashPaddingBottom", + ) + + val nodeRegistry = remember { NodeRegistry() } + val issues = uiState.graphIssues + + LaunchedEffect(uiState.graph) { + val missingNodes = uiState.graph.nodes.filter { nodeRegistry.getNodePosition(it.id) == null } + if (missingNodes.isNotEmpty()) { + val layout = GraphLayout.calculateLayout(uiState.graph) + layout.forEach { (id, pos) -> + if (nodeRegistry.getNodePosition(id) == null) { + nodeRegistry.updateNodePosition(id, pos) + } + } + } + } + + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + BrushGraphContent( + isLandscape = isLandscape, + isNodeSelected = isNodeSelected, + isEdgeSelected = isEdgeSelected, + isErrorPaneOpen = isErrorPaneOpen, + isPreviewExpanded = uiState.isPreviewExpanded, + viewportSize = viewportSize, + onViewportSizeChange = { viewportSize = it }, + canvasSlot = { padding -> + GraphCanvas( + graph = uiState.graph, + zoom = uiState.zoom, + offset = Offset(uiState.offset.x, uiState.offset.y), + onZoomChange = { viewModel.updateZoom(it) }, + onOffsetChange = { viewModel.updateOffset(GraphPoint(it.x, it.y)) }, + onNodeMoveFinished = { viewModel.advanceTutorial(TutorialAction.MOVE_NODE) }, + onNodeClick = { id, _ -> + if (uiState.isSelectionMode) { + viewModel.toggleNodeSelection(id) + } else { + viewModel.onNodeClick(id) + } + }, + onNodeLongPress = { id -> viewModel.enterSelectionMode(id) }, + onNodeDelete = { id -> viewModel.deleteNode(id) }, + isSelectionMode = uiState.isSelectionMode, + selectedNodeIds = uiState.selectedNodeIds, + onSelectAll = { viewModel.selectAllNodes() }, + onDuplicateSelected = { viewModel.duplicateSelectedNodes() }, + onDeleteSelected = { viewModel.deleteSelectedNodes() }, + onDoneSelection = { viewModel.exitSelectionMode() }, + onAddEdge = { from, to, portId -> viewModel.addEdge(from, to, portId) }, + onEdgeClick = { viewModel.onEdgeClick(it) }, + onEdgeDelete = { viewModel.deleteEdge(it) }, + onEdgeDetach = { viewModel.detachEdge(it) }, + onFinalizeEdgeEdit = { oldEdge, fromId, toId, portId -> viewModel.finalizeEdgeEdit(oldEdge, fromId, toId, portId) }, + onCanvasClick = { viewModel.dismissPanes() }, + onPortClick = { nodeId, port -> + val node = uiState.graph.nodes.find { it.id == nodeId } + val nodeData = node?.let { port.inferNodeData(it) } + if (nodeData != null) { + val portPos = nodeRegistry.getPortPosition(nodeId, port.id, uiState.graph) + val nodePos = nodeRegistry.getNodePosition(nodeId) ?: Offset.Zero + val newX = nodePos.x - nodeData.width() - 100f + val newY = portPos.y - nodeData.height() / 2f + val newNodeId = viewModel.addNodeAndConnect(nodeData, nodeId, port.id) + nodeRegistry.updateNodePosition(newNodeId, Offset(newX, newY)) + } + }, + onReorderPorts = { nodeId, fromIndex, toIndex -> viewModel.reorderPorts(nodeId, fromIndex, toIndex) }, + nodeRegistry = nodeRegistry, + selectedEdge = uiState.selectedEdge, + detachedEdge = uiState.detachedEdge, + activeEdgeSourceId = uiState.activeEdgeSourceId, + onNodeDataUpdate = { id, data -> viewModel.updateNodeData(id, data) }, + onChooseColor = { initialColor, onColorSelected -> + colorPickerInitialColor = initialColor + colorPickerOnColorSelected = onColorSelected + showColorPicker = true + }, + textureStore = textureStore, + allTextureIds = uiState.allTextureIds, + onLoadTexture = { texturePickerLauncher.launch(arrayOf("image/*")) }, + strokeRenderer = renderer, + textFieldsLocked = uiState.textFieldsLocked, + selectedNodeId = uiState.selectedNodeId, + brush = viewModel.brush.collectAsStateWithLifecycle().value, + bottomPadding = padding, + ) + }, + inspectorSlot = { + val selectedNode = uiState.graph.nodes.find { it.id == uiState.selectedNodeId } + val selectedEdge = uiState.selectedEdge + val selectionName = if (selectedNode != null) { + stringResource(selectedNode.data.title()) + } else { + stringResource(R.string.bg_label_edge) + } + val titleText = stringResource(R.string.bg_title_inspector_with_name, selectionName) + val selectionTooltip = selectedNode?.data?.getTooltip()?.let { stringResource(it) } + + AdaptiveInspectorPane( + isLandscape = isLandscape, + visible = selectedNode != null || selectedEdge != null, + title = titleText, + tooltipText = selectionTooltip, + onClose = { + viewModel.clearSelectedNode() + viewModel.clearSelectedEdge() + }, + modifier = Modifier.align(if (isLandscape) Alignment.CenterEnd else Alignment.BottomCenter), + ) { + if (selectedNode != null) { + NodeInspector( + node = selectedNode, + onUpdate = { viewModel.updateNodeData(selectedNode.id, it) }, + onDisableChange = { viewModel.setNodeDisabled(selectedNode.id, it) }, + onChooseColor = { initialColor, onColorSelected -> + colorPickerInitialColor = initialColor + colorPickerOnColorSelected = onColorSelected + showColorPicker = true + }, + allTextureIds = uiState.allTextureIds, + onLoadTexture = { texturePickerLauncher.launch(arrayOf("image/*")) }, + strokeRenderer = renderer, + textFieldsLocked = uiState.textFieldsLocked, + onDelete = { viewModel.deleteNode(selectedNode.id) }, + onFieldEditComplete = { viewModel.advanceTutorial(TutorialAction.EDIT_FIELD) }, + onDropdownEditComplete = { viewModel.advanceTutorial(TutorialAction.EDIT_DROPDOWN) }, + ) + } else if (selectedEdge != null) { + val fromNode = uiState.graph.nodes.find { it.id == selectedEdge.fromNodeId } + val toNode = uiState.graph.nodes.find { it.id == selectedEdge.toNodeId } + if (fromNode != null && toNode != null) { + val visiblePorts = toNode.getVisiblePorts(uiState.graph) + val port = visiblePorts.find { it.id == selectedEdge.toPortId } + val inputLabel = port?.label + EdgeInspector( + edge = selectedEdge, + fromNode = fromNode, + toNode = toNode, + inputLabel = inputLabel, + onNodeFocus = { nodeId: String -> viewModel.centerNode(nodeId) }, + onDisableChange = { viewModel.setEdgeDisabled(selectedEdge, it) }, + onDelete = { viewModel.deleteEdge(selectedEdge) }, + onAddNodeBetween = { + val fromNodePos = nodeRegistry.getNodePosition(selectedEdge.fromNodeId) ?: Offset.Zero + val toNodePos = nodeRegistry.getNodePosition(selectedEdge.toNodeId) ?: Offset.Zero + val midpoint = (fromNodePos + toNodePos) / 2f + val newNodeId = viewModel.addNodeBetween(selectedEdge) + if (newNodeId != null) { + nodeRegistry.updateNodePosition(newNodeId, midpoint) + } + }, + ) + } + } + } + }, + notificationPaneSlot = { + NotificationPane( + isLandscape = isLandscape, + viewModel = viewModel, + modifier = Modifier.align(if (isLandscape) Alignment.CenterEnd else Alignment.BottomCenter), + ) + }, + notificationIconSlot = { padding -> + NotificationIcon( + issues = issues, + indicatorPaddingEnd = padding, + onToggleErrorPane = { viewModel.toggleErrorPane() }, + modifier = Modifier.align(Alignment.TopEnd) + ) + }, + previewSlot = { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) { + CollapsiblePreviewPane( + viewModel = viewModel, + strokeRenderer = renderer, + textureStore = textureStore, + onChooseColor = { initialColor, onColorSelected -> + colorPickerInitialColor = initialColor + colorPickerOnColorSelected = onColorSelected + showColorPicker = true + }, + ) + } + }, + menuSlot = { + GraphActionMenu( + onClose = onNavigateUp, + onExport = { + brushExportLauncher.launch("brush_${System.currentTimeMillis()}.brushfamily") + }, + onLoadBrushFile = { brushFilePickerLauncher.launch(arrayOf("*/*")) }, + onSaveToPalette = { + paletteBrushNameInput = "" + showSavePaletteDialog = true + }, + textureStore = textureStore, + onOrganize = viewModel::reorganize, + onDeleteBrush = { viewModel.clearGraph() }, + onTutorialExitRequested = { showTutorialFinishDialog = true }, + savedBrushes = viewModel.savedPaletteBrushes.collectAsStateWithLifecycle().value, + tutorialStep = viewModel.tutorialStep, + isTutorialSandboxMode = viewModel.isTutorialSandboxMode, + onEnterSelectionMode = { viewModel.enterSelectionMode(null) }, + onLoadBrushFamily = { viewModel.loadBrushFamily(it) }, + onLoadFromPalette = { viewModel.loadFromPalette(it, textureStore) }, + onDeleteFromPalette = { viewModel.deleteFromPalette(it) }, + onStartTutorialSandbox = { viewModel.startTutorialSandbox() }, + textFieldsLocked = uiState.textFieldsLocked, + onToggleTextFieldsLocked = { viewModel.toggleTextFieldsLocked() }, + modifier = Modifier.align(Alignment.TopStart).padding(16.dp).zIndex(2f), + ) + }, + fabSlot = { vSize -> + val density = LocalDensity.current.density + val previewHeight = if (uiState.isPreviewExpanded) PREVIEW_HEIGHT_EXPANDED else PREVIEW_HEIGHT_COLLAPSED + val isInspectorOpen = (uiState.selectedNodeId != null || uiState.selectedEdge != null) + val isErrorPaneOpen = uiState.isErrorPaneOpen + val isAnySidePaneOpen = isInspectorOpen || isErrorPaneOpen + + val inspectorWidthPx = INSPECTOR_WIDTH_LANDSCAPE * density + val inspectorHeightPx = INSPECTOR_HEIGHT_PORTRAIT * density + val previewHeightPx = previewHeight * density + + val (visibleWidth, visibleHeight) = + if (isLandscape) { + val w = if (isAnySidePaneOpen) vSize.width - inspectorWidthPx else vSize.width + val h = vSize.height - previewHeightPx + w to h + } else { + val w = vSize.width + val h = if (isAnySidePaneOpen) vSize.height - maxOf(previewHeightPx, inspectorHeightPx) else vSize.height - previewHeightPx + w to h + } + + val visibleCenter = Offset(visibleWidth / 2f, visibleHeight / 2f) + val centerInCanvas = (visibleCenter - Offset(uiState.offset.x, uiState.offset.y)) / uiState.zoom + + CreateNodeSpeedDial( + isLandscape = isLandscape, + isAnySidePaneOpen = isAnySidePaneOpen, + isPreviewExpanded = uiState.isPreviewExpanded, + viewportSize = vSize, + modifier = Modifier.align(Alignment.BottomEnd), + menuContent = { onClose -> + data class SpeedDialAction( + val labelRes: Int, + val icon: androidx.compose.ui.graphics.vector.ImageVector, + val onClick: () -> Unit + ) + + val actions = remember(centerInCanvas) { + listOf( + SpeedDialAction(R.string.bg_coat, Icons.Default.Layers) { + val id = viewModel.addCoatNode() + nodeRegistry.updateNodePosition(id, centerInCanvas) + }, + SpeedDialAction(R.string.bg_paint, Icons.Default.Palette) { + val id = viewModel.addPaintNode() + nodeRegistry.updateNodePosition(id, centerInCanvas) + }, + SpeedDialAction(R.string.bg_tip, Icons.Default.ShapeLine) { + val id = viewModel.addTipNode() + nodeRegistry.updateNodePosition(id, centerInCanvas) + }, + SpeedDialAction(R.string.bg_behavior, Icons.Default.Psychology) { + val id = viewModel.addBehaviorNode() + nodeRegistry.updateNodePosition(id, centerInCanvas) + }, + SpeedDialAction(R.string.bg_color_function, Icons.Default.Palette) { + val id = viewModel.addColorFunctionNode() + nodeRegistry.updateNodePosition(id, centerInCanvas) + }, + SpeedDialAction(R.string.bg_texture_layer, Icons.Default.Layers) { + val id = viewModel.addTextureLayerNode() + nodeRegistry.updateNodePosition(id, centerInCanvas) + }, + ) + } + + actions.forEach { action -> + DropdownMenuItem( + text = { Text(stringResource(action.labelRes)) }, + leadingIcon = { Icon(action.icon, contentDescription = null) }, + onClick = { + action.onClick() + onClose() + } + ) + } + } + ) + }, + tutorialSlot = { vSize -> + TutorialOverlayHost( + tutorialStep = viewModel.tutorialStep, + graph = uiState.graph, + zoom = uiState.zoom, + offset = Offset(uiState.offset.x, uiState.offset.y), + selectedNodeId = uiState.selectedNodeId, + selectedEdge = uiState.selectedEdge, + currentStepIndex = viewModel.currentStepIndex, + isLandscape = isLandscape, + viewportSize = vSize, + isPreviewExpanded = uiState.isPreviewExpanded, + onAdvanceTutorial = { viewModel.advanceTutorial(it) }, + onRegressTutorial = { viewModel.regressTutorial() }, + onCloseTutorial = { showTutorialFinishDialog = true }, + nodeRegistry = nodeRegistry + ) + }, + dialogSlot = { + TutorialFinishDialog( + show = showTutorialFinishDialog, + onDismiss = { showTutorialFinishDialog = false }, + onKeepChanges = { + viewModel.endTutorialSandbox(keepChanges = true) + showTutorialFinishDialog = false + }, + onRestoreOriginal = { + viewModel.endTutorialSandbox(keepChanges = false) + showTutorialFinishDialog = false + } + ) + } + ) + } + + GraphCameraController( + offset = Offset(uiState.offset.x, uiState.offset.y), + tutorialStep = viewModel.tutorialStep, + focusTrigger = uiState.focusTrigger, + graph = uiState.graph, + zoom = uiState.zoom, + isPreviewExpanded = uiState.isPreviewExpanded, + selectedNodeId = uiState.selectedNodeId, + updateOffset = { viewModel.updateOffset(GraphPoint(it.x, it.y)) }, + viewportSize = viewportSize, + context = context, + isLandscape = isLandscape, + maxWidthDp = maxWidth, + nodeRegistry = nodeRegistry + ) + } + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/Notification.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/Notification.kt new file mode 100644 index 0000000..60d6d15 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/Notification.kt @@ -0,0 +1,294 @@ +/* + * * 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.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.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.example.cahier.R +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalDensity +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 com.example.cahier.developer.brushgraph.viewmodel.BrushGraphViewModel +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 android.util.Log + +@Composable +fun NotificationPane( + isLandscape: Boolean, + viewModel: BrushGraphViewModel, + modifier: Modifier = Modifier, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val issues = uiState.graphIssues + val hasErrors = issues.any { it.severity == ValidationSeverity.ERROR } + val hasWarnings = issues.any { it.severity == ValidationSeverity.WARNING } + + AnimatedVisibility( + visible = uiState.isErrorPaneOpen, + 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 + val headerColor = + when { + hasErrors -> MaterialTheme.colorScheme.error + hasWarnings -> MaterialTheme.extendedColorScheme.warning + else -> MaterialTheme.colorScheme.primary + } + val iconColor = + when { + hasErrors -> MaterialTheme.colorScheme.onError + hasWarnings -> MaterialTheme.extendedColorScheme.onWarning + else -> MaterialTheme.colorScheme.onPrimary + } + val headerIcon = + when { + hasErrors -> Icons.Default.Error + hasWarnings -> Icons.Default.Warning + else -> Icons.Default.Info + } + + Surface(color = headerColor, tonalElevation = 2.dp) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp).fillMaxWidth(), + ) { + Icon(headerIcon, contentDescription = null, tint = iconColor) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(R.string.bg_notifications_count, issues.size), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + color = iconColor, + ) + IconButton(onClick = { viewModel.toggleErrorPane() }) { + Icon(Icons.Default.Close, contentDescription = stringResource(R.string.bg_cd_close_pane), tint = iconColor) + } + } + } + LazyColumn(modifier = Modifier.padding(16.dp)) { + val errors = issues.filter { it.severity == ValidationSeverity.ERROR } + val warnings = issues.filter { it.severity == ValidationSeverity.WARNING } + val debugs = issues.filter { it.severity == ValidationSeverity.DEBUG } + + if (errors.isNotEmpty()) { + item { + NotificationGroup( + title = stringResource(R.string.bg_errors), + issues = errors, + icon = Icons.Default.Error, + color = MaterialTheme.colorScheme.error, + viewModel = viewModel, + isLandscape = isLandscape, + ) + } + } + if (warnings.isNotEmpty()) { + item { + NotificationGroup( + title = stringResource(R.string.bg_warnings), + issues = warnings, + icon = Icons.Default.Warning, + color = MaterialTheme.extendedColorScheme.warning, + viewModel = viewModel, + isLandscape = isLandscape, + ) + } + } + if (debugs.isNotEmpty()) { + item { + NotificationGroup( + title = stringResource(R.string.bg_debug), + issues = debugs, + icon = Icons.Default.Info, + color = MaterialTheme.colorScheme.onSurfaceVariant, + viewModel = viewModel, + isLandscape = isLandscape, + ) + } + } + } + } + } + } +} + +@Composable +fun NotificationGroup( + title: String, + issues: List, + icon: ImageVector, + color: Color, + viewModel: BrushGraphViewModel, + isLandscape: Boolean, +) { + var expanded by remember { mutableStateOf(true) } + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) { + Surface( + modifier = Modifier.fillMaxWidth().clickable { expanded = !expanded }, + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = RoundedCornerShape(8.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) { + Icon( + if (expanded) { + Icons.Default.KeyboardArrowDown + } else { + Icons.Default.ChevronRight + }, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.width(8.dp)) + Icon(icon, contentDescription = null, tint = color, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + Text( + text = "$title (${issues.size})", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = color, + ) + } + } + if (expanded) { + Column(modifier = Modifier.padding(start = 16.dp, top = 4.dp)) { + for (issue in issues) { + val density = LocalDensity.current.density + val message = issue.displayMessage.asString() + LaunchedEffect(issue) { + Log.d("NotificationPane", message) + } + Surface( + modifier = + Modifier.fillMaxWidth().padding(vertical = 4.dp).let { + if (issue.nodeId != null) { + it.clickable { viewModel.onIssueClick(issue, isLandscape, density) } + } else { + it + } + }, + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(4.dp), + ) { + Text( + text = message, + modifier = Modifier.padding(8.dp), + style = MaterialTheme.typography.bodySmall, + color = color, + ) + } + } + } + } + } +} + +@Composable +fun NotificationIcon( + issues: List, + indicatorPaddingEnd: androidx.compose.ui.unit.Dp, + onToggleErrorPane: () -> Unit, + modifier: androidx.compose.ui.Modifier = androidx.compose.ui.Modifier, +) { + if (issues.isNotEmpty()) { + val hasErrors = issues.any { it.severity == ValidationSeverity.ERROR } + val hasWarnings = issues.any { it.severity == ValidationSeverity.WARNING } + val icon = + when { + hasErrors -> Icons.Default.Error + hasWarnings -> Icons.Default.Warning + else -> Icons.Default.Info + } + val containerColor = + when { + hasErrors -> MaterialTheme.colorScheme.error + hasWarnings -> MaterialTheme.extendedColorScheme.warning + else -> MaterialTheme.colorScheme.secondary + } + val contentColor = + when { + hasErrors -> MaterialTheme.colorScheme.onError + hasWarnings -> MaterialTheme.extendedColorScheme.onWarning + else -> MaterialTheme.colorScheme.onSecondary + } + + IconButton( + onClick = onToggleErrorPane, + modifier = modifier + .padding(top = 16.dp, end = indicatorPaddingEnd) + .zIndex(2f), + colors = + IconButtonDefaults.iconButtonColors( + containerColor = containerColor, + contentColor = contentColor, + ), + ) { + Icon(icon, contentDescription = stringResource(R.string.bg_cd_show_notifications)) + } + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TutorialOverlay.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TutorialOverlay.kt new file mode 100644 index 0000000..11de16f --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TutorialOverlay.kt @@ -0,0 +1,117 @@ +/* + * * 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.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.BorderStroke +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import com.example.cahier.R +import androidx.compose.ui.unit.dp +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Close +import com.example.cahier.developer.brushgraph.data.TutorialStep +import com.example.cahier.developer.brushgraph.data.TutorialAction + +@Composable +fun TutorialOverlay( + step: TutorialStep, + onNext: () -> Unit, + onClose: () -> Unit, + modifier: Modifier = Modifier, + onBack: (() -> Unit)? = null, +) { + Box( + modifier = modifier + .width(400.dp) + .wrapContentHeight() + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.85f)) + .padding(16.dp) + ) { + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(step.title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(vertical = 4.dp).weight(1f) + ) + IconButton(onClick = onClose) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.bg_cd_close_pane), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + Text( + text = stringResource(step.message), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(vertical = 8.dp) + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + if (onBack != null) { + OutlinedButton( + onClick = onBack, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ), + border = BorderStroke( + 1.dp, + MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.5f) + ) + ) { + Text(stringResource(R.string.bg_back)) + } + } else { + Spacer(modifier = Modifier.width(1.dp)) + } + Button(onClick = onNext) { + Text(if (step.actionRequired == TutorialAction.CLICK_NEXT) stringResource(R.string.bg_next) else stringResource(R.string.bg_got_it)) + } + } + } + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TutorialOverlayHost.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TutorialOverlayHost.kt new file mode 100644 index 0000000..60c4a26 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TutorialOverlayHost.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 androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +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.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import com.example.cahier.developer.brushgraph.data.BrushGraph +import com.example.cahier.developer.brushgraph.data.GraphEdge +import com.example.cahier.developer.brushgraph.data.TutorialAction +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 + +@Composable +fun BoxScope.TutorialOverlayHost( + tutorialStep: TutorialStep?, + graph: BrushGraph, + zoom: Float, + offset: androidx.compose.ui.geometry.Offset, + selectedNodeId: String?, + selectedEdge: GraphEdge?, + currentStepIndex: Int, + isLandscape: Boolean, + viewportSize: androidx.compose.ui.geometry.Size, + isPreviewExpanded: Boolean, + onAdvanceTutorial: (TutorialAction) -> Unit, + onRegressTutorial: () -> Unit, + onCloseTutorial: () -> Unit, + nodeRegistry: NodeRegistry, + modifier: Modifier = Modifier +) { + tutorialStep?.let { step -> + val density = LocalDensity.current + val isInspectorOpen = (selectedNodeId != null || selectedEdge != null) + var overlaySize by remember { mutableStateOf(IntSize.Zero) } + + val tutorialModifier = when (step.anchor) { + TutorialAnchor.SCREEN_CENTER -> Modifier.align(Alignment.Center) + + TutorialAnchor.FAB -> { + if (isInspectorOpen) { + if (isLandscape) { + Modifier.align(Alignment.BottomEnd).padding(bottom = 80.dp, end = (INSPECTOR_WIDTH_LANDSCAPE + 80).dp) + } else { + Modifier.align(Alignment.BottomEnd).padding(bottom = (INSPECTOR_HEIGHT_PORTRAIT + 16).dp, end = 80.dp) + } + } else { + Modifier.align(Alignment.BottomEnd).padding(bottom = 80.dp, end = 80.dp) + } + } + + TutorialAnchor.NODE_CANVAS -> { + val node = step.getTargetNode(graph) + if (node != null) { + val nodePos = nodeRegistry.getNodePosition(node.id) ?: androidx.compose.ui.geometry.Offset.Zero + val nodeCenterX = nodePos.x + node.data.width() / 2f + val nodeTopY = nodePos.y + + val screenX = nodeCenterX * zoom + offset.x + val screenY = nodeTopY * zoom + offset.y + + val paddingPx = with(density) { 16.dp.toPx() } + Modifier.offset { + IntOffset( + (screenX - overlaySize.width / 2).toInt(), + (screenY - overlaySize.height - paddingPx).toInt() + ) + } + } else { + Modifier.align(Alignment.Center) + } + } + + TutorialAnchor.INSPECTOR -> { + if (isLandscape) { + Modifier.align(Alignment.CenterEnd).padding(end = (INSPECTOR_WIDTH_LANDSCAPE + 16).dp) + } else { + Modifier.align(Alignment.BottomCenter).padding(bottom = (INSPECTOR_HEIGHT_PORTRAIT + 16).dp) + } + } + + TutorialAnchor.TEST_CANVAS -> { + val basePadding = if (isPreviewExpanded) PREVIEW_HEIGHT_EXPANDED else PREVIEW_HEIGHT_COLLAPSED + if (isInspectorOpen && !isLandscape) { + Modifier.align(Alignment.BottomCenter).padding(bottom = (maxOf(INSPECTOR_HEIGHT_PORTRAIT, basePadding) + 16).dp) + } else { + Modifier.align(Alignment.BottomCenter).padding(bottom = (basePadding + 16).dp) + } + } + + TutorialAnchor.ACTION_BAR -> Modifier.align(Alignment.TopStart).padding(top = 80.dp, start = 16.dp) + + TutorialAnchor.NOTIFICATION_ICON -> { + val indicatorPaddingEnd = if (isLandscape && isInspectorOpen) (INSPECTOR_WIDTH_LANDSCAPE + 16).dp else 16.dp + Modifier.align(Alignment.TopEnd).padding(top = 80.dp, end = indicatorPaddingEnd) + } + }.zIndex(20f) + + TutorialOverlay( + step = step, + onNext = { onAdvanceTutorial(step.actionRequired) }, + onBack = if (currentStepIndex > 0) { { onRegressTutorial() } } else null, + onClose = onCloseTutorial, + modifier = modifier.then(tutorialModifier).onGloballyPositioned { coordinates -> + overlaySize = coordinates.size + } + ) + } +} diff --git a/app/src/main/java/com/example/cahier/features/drawing/DrawingCanvas.kt b/app/src/main/java/com/example/cahier/features/drawing/DrawingCanvas.kt index f8fe563..fbf46f2 100644 --- a/app/src/main/java/com/example/cahier/features/drawing/DrawingCanvas.kt +++ b/app/src/main/java/com/example/cahier/features/drawing/DrawingCanvas.kt @@ -93,6 +93,7 @@ import com.example.cahier.features.drawing.viewmodel.DrawingCanvasViewModel @Composable fun DrawingCanvas( navigateUp: () -> Unit, + navigateToBrushGraph: () -> Unit, modifier: Modifier = Modifier, drawingCanvasViewModel: DrawingCanvasViewModel = hiltViewModel() ) { @@ -140,7 +141,8 @@ fun DrawingCanvas( DrawingCanvasContent( drawingCanvasViewModel = drawingCanvasViewModel, imagePickerLauncher = imagePickerLauncher, - onNavigateUp = navigateUp + onNavigateUp = navigateUp, + navigateToBrushGraph = navigateToBrushGraph ) } } @@ -198,6 +200,7 @@ private fun DrawingCanvasContent( drawingCanvasViewModel: DrawingCanvasViewModel, imagePickerLauncher: ActivityResultLauncher, onNavigateUp: () -> Unit, + navigateToBrushGraph: () -> Unit, modifier: Modifier = Modifier ) { val activity = LocalActivity.current as ComponentActivity @@ -237,6 +240,7 @@ private fun DrawingCanvasContent( onUndo = drawingCanvasViewModel::undo, onRedo = drawingCanvasViewModel::redo, onExit = onNavigateUp, + onEditActiveBrush = navigateToBrushGraph, onColorPickerClick = { showColorPicker = true }, ) diff --git a/app/src/main/java/com/example/cahier/features/drawing/DrawingToolbox.kt b/app/src/main/java/com/example/cahier/features/drawing/DrawingToolbox.kt index ef82901..9958b08 100644 --- a/app/src/main/java/com/example/cahier/features/drawing/DrawingToolbox.kt +++ b/app/src/main/java/com/example/cahier/features/drawing/DrawingToolbox.kt @@ -68,6 +68,7 @@ fun DrawingToolbox( onUndo: () -> Unit, onRedo: () -> Unit, onExit: () -> Unit, + onEditActiveBrush: () -> Unit, isVertical: Boolean, onColorPickerClick: () -> Unit, modifier: Modifier = Modifier @@ -86,6 +87,7 @@ fun DrawingToolbox( onUndo = onUndo, onRedo = onRedo, onExit = onExit, + onEditActiveBrush = onEditActiveBrush, onColorPickerClick = onColorPickerClick ) } else { @@ -97,6 +99,7 @@ fun DrawingToolbox( onUndo = onUndo, onRedo = onRedo, onExit = onExit, + onEditActiveBrush = onEditActiveBrush, onColorPickerClick = onColorPickerClick ) } @@ -308,6 +311,7 @@ internal fun ToolboxNoteActions( drawingCanvasViewModel: DrawingCanvasViewModel, imagePickerLauncher: ActivityResultLauncher, onExit: () -> Unit, + onEditActiveBrush: () -> Unit, isVertical: Boolean, modifier: Modifier = Modifier ) { @@ -316,7 +320,8 @@ internal fun ToolboxNoteActions( ToolboxNoteActionsContent( drawingCanvasViewModel = drawingCanvasViewModel, imagePickerLauncher = imagePickerLauncher, - onExit = onExit + onExit = onExit, + onEditActiveBrush = onEditActiveBrush ) } } else { @@ -324,7 +329,8 @@ internal fun ToolboxNoteActions( ToolboxNoteActionsContent( drawingCanvasViewModel = drawingCanvasViewModel, imagePickerLauncher = imagePickerLauncher, - onExit = onExit + onExit = onExit, + onEditActiveBrush = onEditActiveBrush ) } } @@ -334,10 +340,12 @@ internal fun ToolboxNoteActions( private fun ToolboxNoteActionsContent( drawingCanvasViewModel: DrawingCanvasViewModel, imagePickerLauncher: ActivityResultLauncher, - onExit: () -> Unit + onExit: () -> Unit, + onEditActiveBrush: () -> Unit ) { val uiState by drawingCanvasViewModel.uiState.collectAsStateWithLifecycle() var optionsMenuExpanded by rememberSaveable { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() IconButton( onClick = { drawingCanvasViewModel.toggleFavorite() }, modifier = Modifier.size(48.dp) @@ -383,6 +391,22 @@ private fun ToolboxNoteActionsContent( expanded = optionsMenuExpanded, onDismissRequest = { optionsMenuExpanded = false } ) { + DropdownMenuItem( + text = { Text("Edit active brush") }, + onClick = { + optionsMenuExpanded = false + coroutineScope.launch { + drawingCanvasViewModel.saveCurrentBrushToAutosave() + onEditActiveBrush() + } + }, + leadingIcon = { + Icon( + painter = painterResource(R.drawable.edit_24px), + contentDescription = null + ) + } + ) DropdownMenuItem( text = { Text(stringResource(R.string.exit)) }, onClick = { diff --git a/app/src/main/java/com/example/cahier/features/drawing/HorizontalToolbox.kt b/app/src/main/java/com/example/cahier/features/drawing/HorizontalToolbox.kt index 79b8307..2cb543b 100644 --- a/app/src/main/java/com/example/cahier/features/drawing/HorizontalToolbox.kt +++ b/app/src/main/java/com/example/cahier/features/drawing/HorizontalToolbox.kt @@ -43,6 +43,7 @@ internal fun HorizontalToolbox( onUndo: () -> Unit, onRedo: () -> Unit, onExit: () -> Unit, + onEditActiveBrush: () -> Unit, onColorPickerClick: () -> Unit, modifier: Modifier = Modifier ) { @@ -99,6 +100,7 @@ internal fun HorizontalToolbox( drawingCanvasViewModel = drawingCanvasViewModel, imagePickerLauncher = imagePickerLauncher, onExit = onExit, + onEditActiveBrush = onEditActiveBrush, isVertical = false ) } diff --git a/app/src/main/java/com/example/cahier/features/drawing/VerticalToolbox.kt b/app/src/main/java/com/example/cahier/features/drawing/VerticalToolbox.kt index 739ee13..21a8e0b 100644 --- a/app/src/main/java/com/example/cahier/features/drawing/VerticalToolbox.kt +++ b/app/src/main/java/com/example/cahier/features/drawing/VerticalToolbox.kt @@ -42,6 +42,7 @@ internal fun VerticalToolbox( onUndo: () -> Unit, onRedo: () -> Unit, onExit: () -> Unit, + onEditActiveBrush: () -> Unit, onColorPickerClick: () -> Unit, modifier: Modifier = Modifier ) { @@ -94,6 +95,7 @@ internal fun VerticalToolbox( drawingCanvasViewModel = drawingCanvasViewModel, imagePickerLauncher = imagePickerLauncher, onExit = onExit, + onEditActiveBrush = onEditActiveBrush, isVertical = true ) } diff --git a/app/src/main/java/com/example/cahier/features/drawing/viewmodel/DrawingCanvasViewModel.kt b/app/src/main/java/com/example/cahier/features/drawing/viewmodel/DrawingCanvasViewModel.kt index f8c8739..cb56b79 100644 --- a/app/src/main/java/com/example/cahier/features/drawing/viewmodel/DrawingCanvasViewModel.kt +++ b/app/src/main/java/com/example/cahier/features/drawing/viewmodel/DrawingCanvasViewModel.kt @@ -40,7 +40,9 @@ import androidx.ink.geometry.MutableParallelogram import androidx.ink.geometry.MutableSegment import androidx.ink.geometry.MutableVec import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer +import androidx.ink.storage.AndroidBrushFamilySerialization import androidx.ink.storage.decode +import androidx.ink.storage.encode import androidx.ink.strokes.Stroke import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel @@ -454,6 +456,28 @@ class DrawingCanvasViewModel @Inject constructor( return _selectedBrush.value } + @OptIn(ExperimentalInkCustomBrushApi::class) + suspend fun saveCurrentBrushToAutosave() { + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + try { + val stream = java.io.ByteArrayOutputStream() + val textureStore = CahierTextureBitmapStore(context) + AndroidBrushFamilySerialization.encode( + _selectedBrush.value.family, + stream, + textureStore + ) + val encodedBrushFamily = stream.toByteArray() + customBrushDao.saveCustomBrush( + com.example.cahier.developer.brushdesigner.data.CustomBrushEntity("__autosave__", encodedBrushFamily) + ) + Log.d(TAG, "Auto saved brush to database successfully") + } catch (e: Exception) { + Log.e(TAG, "Error auto saving brush to database", e) + } + } + } + @OptIn(ExperimentalInkCustomBrushApi::class) private fun loadCustomBrushes() { viewModelScope.launch(Dispatchers.IO) { diff --git a/app/src/main/java/com/example/cahier/features/home/CahierHomeScreen.kt b/app/src/main/java/com/example/cahier/features/home/CahierHomeScreen.kt index 0a5595e..6684754 100644 --- a/app/src/main/java/com/example/cahier/features/home/CahierHomeScreen.kt +++ b/app/src/main/java/com/example/cahier/features/home/CahierHomeScreen.kt @@ -103,6 +103,7 @@ fun HomePane( navigateToCanvas: (Long) -> Unit, navigateToDrawingCanvas: (Long) -> Unit, navigateToBrushDesigner: () -> Unit = {}, + navigateToBrushGraph: () -> Unit = {}, navigateUp: () -> Unit, modifier: Modifier = Modifier, forceCompact: Boolean? = null, @@ -176,6 +177,7 @@ fun HomePane( navigateToCanvas = navigateToCanvas, navigateToDrawingCanvas = navigateToDrawingCanvas, navigateToBrushDesigner = navigateToBrushDesigner, + navigateToBrushGraph = navigateToBrushGraph, navigateUp = navigateUp ) } @@ -196,6 +198,7 @@ private fun CahierNavigationSuite( navigateToCanvas: (Long) -> Unit, navigateToDrawingCanvas: (Long) -> Unit, navigateToBrushDesigner: () -> Unit, + navigateToBrushGraph: () -> Unit, navigateUp: () -> Unit ) { NavigationSuiteScaffold( @@ -310,6 +313,7 @@ private fun CahierNavigationSuite( AppDestinations.Settings -> { SettingsScreen( navigateToBrushDesigner = navigateToBrushDesigner, + navigateToBrushGraph = navigateToBrushGraph, modifier = Modifier.fillMaxSize() ) } diff --git a/app/src/main/java/com/example/cahier/features/home/SettingsScreen.kt b/app/src/main/java/com/example/cahier/features/home/SettingsScreen.kt index 421430d..1477aae 100644 --- a/app/src/main/java/com/example/cahier/features/home/SettingsScreen.kt +++ b/app/src/main/java/com/example/cahier/features/home/SettingsScreen.kt @@ -60,11 +60,13 @@ import kotlinx.coroutines.launch @Composable fun SettingsScreen( navigateToBrushDesigner: () -> Unit, + navigateToBrushGraph: () -> Unit, modifier: Modifier = Modifier, viewModel: SettingsViewModel = hiltViewModel() ) { val isRoleAvailable by viewModel.isRoleAvailable.collectAsStateWithLifecycle() val isRoleHeld by viewModel.isRoleHeld.collectAsStateWithLifecycle() + val isUsingGraphUi by viewModel.isUsingGraphUi.collectAsStateWithLifecycle() LocalContext.current val coroutineScope = rememberCoroutineScope() @@ -197,7 +199,13 @@ fun SettingsScreen( } FilledTonalButton( - onClick = { navigateToBrushDesigner() } + onClick = { + if (isUsingGraphUi) { + navigateToBrushGraph() + } else { + navigateToBrushDesigner() + } + } ) { Text(stringResource(R.string.settings_launch)) } @@ -220,9 +228,9 @@ fun SettingsScreen( ) } Switch( - checked = false, - onCheckedChange = null, - enabled = false + checked = isUsingGraphUi, + onCheckedChange = { viewModel.setUsingGraphUi(it) }, + enabled = true ) } } diff --git a/app/src/main/java/com/example/cahier/features/home/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/example/cahier/features/home/viewmodel/SettingsViewModel.kt index 8a57d66..5977fee 100644 --- a/app/src/main/java/com/example/cahier/features/home/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/example/cahier/features/home/viewmodel/SettingsViewModel.kt @@ -48,6 +48,13 @@ class SettingsViewModel @Inject constructor( private val _isRoleHeld = MutableStateFlow(false) val isRoleHeld: StateFlow = _isRoleHeld.asStateFlow() + private val _isUsingGraphUi = MutableStateFlow(true) + val isUsingGraphUi: StateFlow = _isUsingGraphUi.asStateFlow() + + fun setUsingGraphUi(isDefault: Boolean) { + _isUsingGraphUi.value = isDefault + } + private val roleManager: RoleManager? by lazy { context.getSystemService(RoleManager::class.java) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4805c40..f5600d4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -89,11 +89,11 @@ Stock Brushes My Palette No saved brushes yet - Save to Palette + Save to Cahier Palette Import Export More options - Save to Cahier Palette + Save to Palette • This brush will appear in the main Cahier toolbox.\n• Large textures are stored in RAM. Avoid saving many texture-heavy brushes to prevent performance lag or memory issues. Brush Name Tip Shape @@ -1035,4 +1035,4 @@ Window Size (ms) Upsampling Frequency (Hz) - + diff --git a/app/src/test/java/com/example/cahier/ScreenshotTest.kt b/app/src/test/java/com/example/cahier/ScreenshotTest.kt index 93a83f1..018669b 100644 --- a/app/src/test/java/com/example/cahier/ScreenshotTest.kt +++ b/app/src/test/java/com/example/cahier/ScreenshotTest.kt @@ -30,6 +30,8 @@ class ScreenshotTest { HomePane( navigateToCanvas = { _ -> }, navigateToDrawingCanvas = { _ -> }, + navigateToBrushDesigner = {}, + navigateToBrushGraph = {}, navigateUp = {}, homeScreenViewModel = fakeViewModel ) From f8f87be6d6463950253d03437f93b599af42ad06 Mon Sep 17 00:00:00 2001 From: Maxwell Metzger Mitchell <60010512+maxmmitchell@users.noreply.github.com> Date: Tue, 28 Apr 2026 07:55:54 +0000 Subject: [PATCH 2/5] Fix from PR 6 + Gemini review comments --- .../cahier/core/navigation/CahierNavGraph.kt | 3 +- .../brushgraph/ui/BrushGraphContent.kt | 1 - .../brushgraph/ui/BrushGraphScreen.kt | 39 ++++++++++--------- .../viewmodel/BrushGraphViewModel.kt | 2 +- .../viewmodel/DrawingCanvasViewModel.kt | 6 ++- 5 files changed, 28 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/com/example/cahier/core/navigation/CahierNavGraph.kt b/app/src/main/java/com/example/cahier/core/navigation/CahierNavGraph.kt index e96ae74..8719a07 100644 --- a/app/src/main/java/com/example/cahier/core/navigation/CahierNavGraph.kt +++ b/app/src/main/java/com/example/cahier/core/navigation/CahierNavGraph.kt @@ -29,6 +29,7 @@ import com.example.cahier.features.drawing.DrawingCanvas import com.example.cahier.features.home.HomeDestination import com.example.cahier.features.home.HomePane import com.example.cahier.features.text.TextNoteCanvasScreen +import com.example.cahier.developer.brushgraph.ui.BrushGraphScreen @OptIn(ExperimentalComposeApi::class) @@ -89,7 +90,7 @@ fun CahierNavHost( ) } composable(route = BrushGraphDestination.route) { - com.example.cahier.developer.brushgraph.ui.BrushGraphScreen( + BrushGraphScreen( onNavigateUp = { navController.navigateUp() } ) } diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphContent.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphContent.kt index 3b28217..63b6b00 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphContent.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphContent.kt @@ -34,7 +34,6 @@ import androidx.compose.ui.unit.toSize @Composable fun BrushGraphContent( - isLandscape: Boolean, isNodeSelected: Boolean, isEdgeSelected: Boolean, isErrorPaneOpen: Boolean, diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt index 77ff6b4..b7bdfe2 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt @@ -156,8 +156,10 @@ fun BrushGraphScreen( val uri = pendingTextureUri!! val name = textureNameInput scope.launch { - val bitmap = context.contentResolver.openInputStream(uri)?.use { + val bitmap = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it) + } } if (bitmap != null) { textureStore.loadTexture(name, bitmap) @@ -176,21 +178,23 @@ fun BrushGraphScreen( uri?.let { scope.launch { try { - val family = context.contentResolver.openInputStream(it)?.use { stream -> - try { - AndroidBrushFamilySerialization.decode( - stream, - BrushFamilyDecodeCallback { id: String, bitmap: Bitmap? -> - if (bitmap != null) { - textureStore.loadTexture(id, bitmap) - viewModel.updateAllTextureIds() + val family = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + context.contentResolver.openInputStream(it)?.use { stream -> + try { + AndroidBrushFamilySerialization.decode( + stream, + BrushFamilyDecodeCallback { id: String, bitmap: Bitmap? -> + if (bitmap != null) { + textureStore.loadTexture(id, bitmap) + viewModel.updateAllTextureIds() + } + id } - id - } - ) - } catch (e: Exception) { - Log.d("BrushGraphWidget", "Failed to decode with AndroidBrushFamilySerialization, trying legacy fallback") - null + ) + } catch (e: Exception) { + Log.d("BrushGraphWidget", "Failed to decode with AndroidBrushFamilySerialization, trying legacy fallback") + null + } } } @@ -242,7 +246,7 @@ fun BrushGraphScreen( onPaletteBrushNameInputChange = { paletteBrushNameInput = it }, onConfirm = { if (paletteBrushNameInput.isNotBlank()) { - viewModel.saveToPalette(paletteBrushNameInput, textureStore) + viewModel.saveToPalette(paletteBrushNameInput) showSavePaletteDialog = false paletteBrushNameInput = "" } @@ -297,7 +301,6 @@ fun BrushGraphScreen( Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { BrushGraphContent( - isLandscape = isLandscape, isNodeSelected = isNodeSelected, isEdgeSelected = isEdgeSelected, isErrorPaneOpen = isErrorPaneOpen, @@ -484,7 +487,7 @@ fun BrushGraphScreen( isTutorialSandboxMode = viewModel.isTutorialSandboxMode, onEnterSelectionMode = { viewModel.enterSelectionMode(null) }, onLoadBrushFamily = { viewModel.loadBrushFamily(it) }, - onLoadFromPalette = { viewModel.loadFromPalette(it, textureStore) }, + onLoadFromPalette = { viewModel.loadFromPalette(it) }, onDeleteFromPalette = { viewModel.deleteFromPalette(it) }, onStartTutorialSandbox = { viewModel.startTutorialSandbox() }, textFieldsLocked = uiState.textFieldsLocked, 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..9e8740b 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 @@ -100,7 +100,7 @@ data class BrushGraphUiState( @HiltViewModel class BrushGraphViewModel @Inject constructor( private val customBrushDao: CustomBrushDao, - private val textureStore: CahierTextureBitmapStore, + public val textureStore: CahierTextureBitmapStore, private val repository: BrushGraphRepository ) : ViewModel() { diff --git a/app/src/main/java/com/example/cahier/features/drawing/viewmodel/DrawingCanvasViewModel.kt b/app/src/main/java/com/example/cahier/features/drawing/viewmodel/DrawingCanvasViewModel.kt index cb56b79..a567b74 100644 --- a/app/src/main/java/com/example/cahier/features/drawing/viewmodel/DrawingCanvasViewModel.kt +++ b/app/src/main/java/com/example/cahier/features/drawing/viewmodel/DrawingCanvasViewModel.kt @@ -461,7 +461,6 @@ class DrawingCanvasViewModel @Inject constructor( kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { try { val stream = java.io.ByteArrayOutputStream() - val textureStore = CahierTextureBitmapStore(context) AndroidBrushFamilySerialization.encode( _selectedBrush.value.family, stream, @@ -469,7 +468,10 @@ class DrawingCanvasViewModel @Inject constructor( ) val encodedBrushFamily = stream.toByteArray() customBrushDao.saveCustomBrush( - com.example.cahier.developer.brushdesigner.data.CustomBrushEntity("__autosave__", encodedBrushFamily) + com.example.cahier.developer.brushdesigner.data.CustomBrushEntity( + com.example.cahier.developer.brushdesigner.data.AUTOSAVE_KEY, + encodedBrushFamily + ) ) Log.d(TAG, "Auto saved brush to database successfully") } catch (e: Exception) { From eff1c2155e0fa8f7b8d0fe611cd16f62bc6b684d Mon Sep 17 00:00:00 2001 From: Maxwell Metzger Mitchell <60010512+maxmmitchell@users.noreply.github.com> Date: Fri, 1 May 2026 18:41:40 +0000 Subject: [PATCH 3/5] Show top issue on test canvas --- .../brushgraph/ui/BrushGraphScreen.kt | 40 +++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt index b7bdfe2..e999c90 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt @@ -84,6 +84,7 @@ 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.GraphValidationException +import com.example.cahier.developer.brushgraph.data.ValidationSeverity import com.example.cahier.developer.brushgraph.ui.INSPECTOR_HEIGHT_PORTRAIT import com.example.cahier.developer.brushgraph.ui.INSPECTOR_WIDTH_LANDSCAPE import com.example.cahier.developer.brushgraph.data.DisplayText @@ -455,15 +456,38 @@ fun BrushGraphScreen( }, previewSlot = { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) { + val topIssue = remember(issues) { + issues.firstOrNull { it.severity == ValidationSeverity.ERROR } ?: + issues.firstOrNull { it.severity == ValidationSeverity.WARNING } + } CollapsiblePreviewPane( - viewModel = viewModel, - strokeRenderer = renderer, - textureStore = textureStore, - onChooseColor = { initialColor, onColorSelected -> - colorPickerInitialColor = initialColor - colorPickerOnColorSelected = onColorSelected - showColorPicker = true - }, + isPreviewExpanded = uiState.isPreviewExpanded, + isInvertedCanvas = uiState.isDarkCanvas, + testAutoUpdateStrokes = uiState.testAutoUpdateStrokes, + brushColor = uiState.testBrushColor ?: 0, + brushSize = uiState.testBrushSize, + brush = viewModel.brush.collectAsStateWithLifecycle().value, + strokeList = viewModel.strokeList, + strokeRenderer = renderer, + textureStore = textureStore, + topIssue = topIssue, + onGetNextBrush = { viewModel.brush.value }, + onTogglePreviewExpanded = { viewModel.togglePreviewExpanded() }, + onClearStrokes = { viewModel.clearStrokes() }, + onToggleCanvasTheme = { viewModel.toggleCanvasTheme() }, + onSetTestAutoUpdateStrokes = { viewModel.setTestAutoUpdateStrokes(it) }, + onUpdateTestBrushColor = { viewModel.updateTestBrushColor(it) }, + onUpdateTestBrushSize = { viewModel.updateTestBrushSize(it) }, + onStrokesAdded = { strokes -> + viewModel.strokeList.addAll(strokes) + viewModel.advanceTutorial(TutorialAction.DRAW_ON_CANVAS) + }, + onChooseColor = { initialColor, onColorSelected -> + colorPickerInitialColor = initialColor + colorPickerOnColorSelected = onColorSelected + showColorPicker = true + }, + onToggleNotificationPane = { viewModel.toggleErrorPane() } ) } }, From 92d79256829ee0c1851ccdc4521861140b097685 Mon Sep 17 00:00:00 2001 From: Maxwell Metzger Mitchell <60010512+maxmmitchell@users.noreply.github.com> Date: Fri, 1 May 2026 19:55:15 +0000 Subject: [PATCH 4/5] Fix duplicate node positioning --- .../cahier/developer/brushgraph/ui/BrushGraphScreen.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt index e999c90..f8b325a 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt @@ -328,7 +328,15 @@ fun BrushGraphScreen( isSelectionMode = uiState.isSelectionMode, selectedNodeIds = uiState.selectedNodeIds, onSelectAll = { viewModel.selectAllNodes() }, - onDuplicateSelected = { viewModel.duplicateSelectedNodes() }, + onDuplicateSelected = { + val idMap = viewModel.duplicateSelectedNodes() + idMap.forEach { (oldId, newId) -> + val oldPos = nodeRegistry.getNodePosition(oldId) + if (oldPos != null) { + nodeRegistry.updateNodePosition(newId, oldPos + Offset(50f, 50f)) + } + } + }, onDeleteSelected = { viewModel.deleteSelectedNodes() }, onDoneSelection = { viewModel.exitSelectionMode() }, onAddEdge = { from, to, portId -> viewModel.addEdge(from, to, portId) }, From ae1b99893bac4d6b0885d3e2dea92a1793ec1aec Mon Sep 17 00:00:00 2001 From: Maxwell Metzger Mitchell <60010512+maxmmitchell@users.noreply.github.com> Date: Tue, 5 May 2026 12:29:17 +0000 Subject: [PATCH 5/5] Make tutorial overlay slightly more opaque and swallow taps --- .../cahier/developer/brushgraph/ui/TutorialOverlay.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TutorialOverlay.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TutorialOverlay.kt index 11de16f..160e58c 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TutorialOverlay.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TutorialOverlay.kt @@ -16,6 +16,8 @@ package com.example.cahier.developer.brushgraph.ui import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding @@ -34,6 +36,7 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -59,8 +62,12 @@ fun TutorialOverlay( .width(400.dp) .wrapContentHeight() .clip(MaterialTheme.shapes.medium) - .background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.85f)) + .background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.95f)) .padding(16.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { /* Do nothing, just swallow the click */ } ) { Column { Row(