diff --git a/.gitignore b/.gitignore index f268043..4030183 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ .externalNativeBuild .cxx local.properties +ink-proto/bin/ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3629ac8..132c61f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -164,6 +164,9 @@ dependencies { //Protobuf implementation(project(":ink-proto")) implementation(libs.protobuf.javalite) + + //Color Picker + implementation(libs.compose.color.picker.android) } java { toolchain { diff --git a/app/src/androidTest/java/com/example/cahier/CahierListDetailTest.kt b/app/src/androidTest/java/com/example/cahier/CahierListDetailTest.kt index 0f57974..cf7f3ac 100644 --- a/app/src/androidTest/java/com/example/cahier/CahierListDetailTest.kt +++ b/app/src/androidTest/java/com/example/cahier/CahierListDetailTest.kt @@ -1,3 +1,24 @@ +/* + * + * * + * * * 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 import androidx.compose.runtime.Composable @@ -28,7 +49,7 @@ class CahierListDetailTest { DeviceConfigurationOverride( DeviceConfigurationOverride.ForcedSize(compatWidthWindow) ) { - HomeContent() + HomeContent(forceCompact = true) } } @@ -42,7 +63,7 @@ class CahierListDetailTest { DeviceConfigurationOverride( DeviceConfigurationOverride.ForcedSize(mediumWidthWindow) ) { - HomeContent() + HomeContent(forceCompact = false) } } @@ -62,11 +83,12 @@ class CahierListDetailTest { ) @Composable - private fun HomeContent() { + private fun HomeContent(forceCompact: Boolean) { HomePane( navigateToCanvas = { _ -> }, navigateToDrawingCanvas = { _ -> }, navigateUp = {}, + forceCompact = forceCompact, homeScreenViewModel = fakeViewModel ) } diff --git a/app/src/androidTest/java/com/example/cahier/developer/brushdesigner/viewmodel/BrushDesignerViewModelTest.kt b/app/src/androidTest/java/com/example/cahier/developer/brushdesigner/viewmodel/BrushDesignerViewModelTest.kt new file mode 100644 index 0000000..162b46e --- /dev/null +++ b/app/src/androidTest/java/com/example/cahier/developer/brushdesigner/viewmodel/BrushDesignerViewModelTest.kt @@ -0,0 +1,130 @@ +package com.example.cahier.developer.brushdesigner.viewmodel + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.example.cahier.developer.brushdesigner.data.BrushDesignerRepository +import com.example.cahier.developer.brushdesigner.data.CustomBrushDao +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltAndroidTest +class BrushDesignerViewModelTest { + + @get:Rule + var hiltRule = HiltAndroidRule(this) + + private val testDispatcher = UnconfinedTestDispatcher() + + @Inject + lateinit var customBrushDao: CustomBrushDao + + @Inject + lateinit var repository: BrushDesignerRepository + + private lateinit var viewModel: BrushDesignerViewModel + + @Before + fun setup() { + hiltRule.inject() + Dispatchers.setMain(testDispatcher) + + val context = ApplicationProvider.getApplicationContext() + viewModel = BrushDesignerViewModel(context, repository, customBrushDao) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun updateTip_modifies_correct_coat_index() = runTest { + assertEquals( + 1.0f, + viewModel.activeBrushProto.value.getCoats(0).tip.scaleX, + 0.01f + ) + + viewModel.updateTip { it.setScaleX(2.5f) } + + assertEquals( + 2.5f, + viewModel.activeBrushProto.value.getCoats(0).tip.scaleX, + 0.01f + ) + } + + @Test + fun addBehavior_stacks_multiple_rules() = runTest { + viewModel.clearBehaviors() + assertEquals( + 0, + viewModel.activeBrushProto.value.getCoats(0).tip.behaviorsCount + ) + + val node1 = ink.proto.BrushBehavior.Node.newBuilder().setConstantNode( + ink.proto.BrushBehavior.ConstantNode.newBuilder().setValue(1f) + ).build() + viewModel.addBehavior(listOf(node1)) + + val node2 = ink.proto.BrushBehavior.Node.newBuilder().setConstantNode( + ink.proto.BrushBehavior.ConstantNode.newBuilder().setValue(2f) + ).build() + viewModel.addBehavior(listOf(node2)) + + val behaviors = viewModel.activeBrushProto.value.getCoats(0).tip.behaviorsList + assertEquals(2, behaviors.size) + } + + @Test + fun addNewCoat_increments_count_and_switches_selection() = runTest { + assertEquals(1, viewModel.activeBrushProto.value.coatsCount) + assertEquals(0, viewModel.selectedCoatIndex.value) + + viewModel.addNewCoat() + + assertEquals(2, viewModel.activeBrushProto.value.coatsCount) + assertEquals(1, viewModel.selectedCoatIndex.value) + } + + @Test + fun saveToPalette_persists_to_dao() = runTest { + val brushName = "Test Persistence Brush" + viewModel.updateClientBrushFamilyId("test-id") + + viewModel.saveToPalette(brushName).join() + + val savedBrushes = customBrushDao.getAllCustomBrushes().first() + assertTrue(savedBrushes.any { it.name == brushName }) + } + + @Test + fun previewBrushFamily_is_null_on_invalid_proto() = runTest { + val invalidRepo = BrushDesignerRepository() + invalidRepo.updateActiveBrushProto(ink.proto.BrushFamily.newBuilder().build()) + + val vm = BrushDesignerViewModel( + ApplicationProvider.getApplicationContext(), + invalidRepo, + customBrushDao + ) + + assertNull(vm.previewBrushFamily.value) + } +} 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 55c11ee..af909bb 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 @@ -26,6 +26,7 @@ import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.navArgument +import com.example.cahier.developer.brushdesigner.ui.BrushDesignerScreen import com.example.cahier.features.drawing.DrawingCanvas import com.example.cahier.features.home.HomeDestination import com.example.cahier.features.home.HomePane @@ -54,7 +55,11 @@ fun CahierNavHost( navigateUp = { navController.navigateUp() }, - ) + navigateToBrushDesigner = { + navController.navigate(BrushDesignerDestination.route) + }, + + ) } composable( route = TextCanvasDestination.routeWithArgs, @@ -76,6 +81,12 @@ fun CahierNavHost( navigateUp = { navController.navigateUp() }, ) } + composable(route = BrushDesignerDestination.route) { + BrushDesignerScreen( + onNavigateUp = { navController.navigateUp() } + ) + } + } } @@ -90,4 +101,8 @@ object DrawingCanvasDestination : NavigationDestination { override val route = "drawing_canvas" const val NOTE_ID_ARG = "noteId" val routeWithArgs = "$route/{$NOTE_ID_ARG}" -} \ No newline at end of file +} + +object BrushDesignerDestination : NavigationDestination { + override val route = "brush_designer" +} 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 33b0bda..6dd6b3c 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 @@ -1,3 +1,24 @@ +/* + * + * * + * * * 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.core.ui import android.content.Context diff --git a/app/src/main/java/com/example/cahier/core/ui/theme/Color.kt b/app/src/main/java/com/example/cahier/core/ui/theme/Color.kt index 1f8b514..080332d 100644 --- a/app/src/main/java/com/example/cahier/core/ui/theme/Color.kt +++ b/app/src/main/java/com/example/cahier/core/ui/theme/Color.kt @@ -26,4 +26,11 @@ val Pink80 = Color(0xFFEFB8C8) val Purple40 = Color(0xFF6650a4) val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) \ No newline at end of file +val Pink40 = Color(0xFF7D5260) + +// Brush Designer: color picker presets +val BrushBlack = Color(0xFF000000) +val BrushRed = Color(0xFFFF0000) +val BrushBlue = Color(0xFF0000FF) +val BrushGreen = Color(0xFF00FF00) +val BrushYellow = Color(0xFFFFFF00) \ No newline at end of file diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BehaviorsTab.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BehaviorsTab.kt new file mode 100644 index 0000000..10457ca --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BehaviorsTab.kt @@ -0,0 +1,304 @@ +/* + * 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.brushdesigner.ui + +import androidx.compose.foundation.layout.Arrangement +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.example.cahier.R +import ink.proto.BrushBehavior +import ink.proto.BrushFamily as ProtoBrushFamily + +/** + * Tab 2: Dynamics & Behaviors controls — editable behavior stack with nested + * node graph editor, standard dynamics presets, and advanced dynamics presets. + * + * Uses [EditableListWidget] for managing behaviors (outer list) and nodes + * within each behavior (inner nested list), with [NodeEditor] for + * type-specific node editing. + * + * Stateless: receives data and callbacks, does not access ViewModel. + */ +@Composable +internal fun BehaviorsTabContent( + activeProto: ProtoBrushFamily, + selectedCoatIndex: Int, + onUpdateBehaviors: (List) -> Unit, + onClearBehaviors: () -> Unit, + onAddBehavior: (List) -> Unit, + onAddSmoothedBehavior: (BrushBehavior.Source, BrushBehavior.Target, Float) -> Unit, + onAddJitterBehavior: (BrushBehavior.Target) -> Unit +) { + val behaviors = activeProto + .coatsList.getOrNull(selectedCoatIndex)?.tip?.behaviorsList + ?: emptyList() + + Text( + stringResource(R.string.brush_designer_dynamics_behaviors), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 8.dp) + ) + + EditableListWidget( + title = stringResource(R.string.brush_designer_behavior_stack), + items = behaviors, + defaultItem = BrushBehavior.newBuilder() + .addNodes( + BrushBehavior.Node.newBuilder().setSourceNode( + BrushBehavior.SourceNode.newBuilder() + .setSource(BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE) + .setSourceValueRangeStart(0f) + .setSourceValueRangeEnd(1f) + .setSourceOutOfRangeBehavior( + BrushBehavior.OutOfRange.OUT_OF_RANGE_CLAMP + ) + ) + ) + .addNodes( + BrushBehavior.Node.newBuilder().setTargetNode( + BrushBehavior.TargetNode.newBuilder() + .setTarget(BrushBehavior.Target.TARGET_SIZE_MULTIPLIER) + .setTargetModifierRangeStart(0.5f) + .setTargetModifierRangeEnd(1.5f) + ) + ) + .build(), + onItemsChanged = onUpdateBehaviors, + itemHeader = { behavior -> + val source = behavior.nodesList + .find { it.hasSourceNode() } + ?.sourceNode?.source?.name?.replace("SOURCE_", "") + ?: "INPUT" + val target = behavior.nodesList + .find { it.hasTargetNode() } + ?.targetNode?.target?.name?.replace("TARGET_", "") + ?: "OUTPUT" + "$source ➔ $target" + }, + editorContent = { behavior, onBehaviorChanged -> + BehaviorNodeGraphEditor( + behavior = behavior, + onBehaviorChanged = onBehaviorChanged + ) + } + ) + + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(8.dp)) + + StandardDynamicsSection(onAddBehavior = onAddBehavior) + + Spacer(modifier = Modifier.height(16.dp)) + + AdvancedDynamicsSection( + onAddSmoothedBehavior = onAddSmoothedBehavior, + onAddJitterBehavior = onAddJitterBehavior + ) +} + +/** + * Nested node graph editor for a single [BrushBehavior]. + * Uses a nested [EditableListWidget] to manage the ordered list of nodes. + */ +@Composable +private fun BehaviorNodeGraphEditor( + behavior: BrushBehavior, + onBehaviorChanged: (BrushBehavior) -> Unit +) { + Text( + stringResource(R.string.brush_designer_nodes_in_behavior), + style = MaterialTheme.typography.labelLarge + ) + + EditableListWidget( + title = "", + items = behavior.nodesList, + defaultItem = BrushBehavior.Node.newBuilder() + .setConstantNode( + BrushBehavior.ConstantNode.newBuilder().setValue(1f) + ) + .build(), + onItemsChanged = { newNodes -> + onBehaviorChanged( + behavior.toBuilder().clearNodes().addAllNodes(newNodes).build() + ) + }, + itemHeader = { node -> + node.nodeCase.name.replace("_NODE", "") + }, + editorContent = { node, onNodeChanged -> + NodeEditor(node = node, onNodeChanged = onNodeChanged) + } + ) +} + +@Composable +private fun StandardDynamicsSection( + onAddBehavior: (List) -> Unit +) { + Text( + stringResource(R.string.brush_designer_standard_dynamics), + style = MaterialTheme.typography.labelLarge + ) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(modifier = Modifier.weight(1f), onClick = { + val s = BrushBehavior.Node.newBuilder().setSourceNode( + BrushBehavior.SourceNode.newBuilder() + .setSource(BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE) + .setSourceValueRangeStart(0f).setSourceValueRangeEnd(1f) + .setSourceOutOfRangeBehavior( + BrushBehavior.OutOfRange.OUT_OF_RANGE_CLAMP + ) + ).build() + val t = BrushBehavior.Node.newBuilder().setTargetNode( + BrushBehavior.TargetNode.newBuilder() + .setTarget(BrushBehavior.Target.TARGET_SIZE_MULTIPLIER) + .setTargetModifierRangeStart(0.5f).setTargetModifierRangeEnd(1.5f) + ).build() + onAddBehavior(listOf(s, t)) + }) { Text(stringResource(R.string.brush_designer_pressure_size)) } + + Button(modifier = Modifier.weight(1f), onClick = { + val s = BrushBehavior.Node.newBuilder().setSourceNode( + BrushBehavior.SourceNode.newBuilder() + .setSource( + BrushBehavior.Source + .SOURCE_SPEED_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND + ) + .setSourceValueRangeStart(0f).setSourceValueRangeEnd(50f) + .setSourceOutOfRangeBehavior( + BrushBehavior.OutOfRange.OUT_OF_RANGE_CLAMP + ) + ).build() + val t = BrushBehavior.Node.newBuilder().setTargetNode( + BrushBehavior.TargetNode.newBuilder() + .setTarget(BrushBehavior.Target.TARGET_SIZE_MULTIPLIER) + .setTargetModifierRangeStart(0.2f).setTargetModifierRangeEnd(2.0f) + ).build() + onAddBehavior(listOf(s, t)) + }) { Text(stringResource(R.string.brush_designer_speed_size)) } + } + + Button(modifier = Modifier.fillMaxWidth(), onClick = { + val s = BrushBehavior.Node.newBuilder().setSourceNode( + BrushBehavior.SourceNode.newBuilder() + .setSource( + BrushBehavior.Source + .SOURCE_SPEED_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND + ) + .setSourceValueRangeStart(5f).setSourceValueRangeEnd(40f) + .setSourceOutOfRangeBehavior( + BrushBehavior.OutOfRange.OUT_OF_RANGE_CLAMP + ) + ).build() + val t = BrushBehavior.Node.newBuilder().setTargetNode( + BrushBehavior.TargetNode.newBuilder() + .setTarget(BrushBehavior.Target.TARGET_OPACITY_MULTIPLIER) + .setTargetModifierRangeStart(1.0f).setTargetModifierRangeEnd(0.1f) + ).build() + onAddBehavior(listOf(s, t)) + }) { Text(stringResource(R.string.brush_designer_speed_opacity)) } + } +} + +@Composable +private fun AdvancedDynamicsSection( + onAddSmoothedBehavior: (BrushBehavior.Source, BrushBehavior.Target, Float) -> Unit, + onAddJitterBehavior: (BrushBehavior.Target) -> Unit +) { + Text( + stringResource(R.string.brush_designer_advanced_dynamics), + style = MaterialTheme.typography.labelLarge + ) + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + FilledTonalButton( + modifier = Modifier.fillMaxWidth(), + onClick = { + onAddSmoothedBehavior( + BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE, + BrushBehavior.Target.TARGET_SIZE_MULTIPLIER, + 0.15f + ) + } + ) { + Icon( + painterResource(R.drawable.brush_24px), + null, + Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.brush_designer_smooth_pressure_size)) + } + + FilledTonalButton( + modifier = Modifier.fillMaxWidth(), + onClick = { + onAddJitterBehavior( + BrushBehavior.Target.TARGET_SLANT_OFFSET_IN_RADIANS + ) + } + ) { + Icon( + painterResource(R.drawable.texture_24px), + null, + Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.brush_designer_pencil_jitter)) + } + + FilledTonalButton( + modifier = Modifier.fillMaxWidth(), + onClick = { + onAddSmoothedBehavior( + BrushBehavior.Source + .SOURCE_SPEED_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND, + BrushBehavior.Target.TARGET_OPACITY_MULTIPLIER, + 0.3f + ) + } + ) { + Icon( + painterResource(R.drawable.opacity_24px), + null, + Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.brush_designer_smooth_speed_opacity)) + } + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerComponents.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerComponents.kt new file mode 100644 index 0000000..d7c8ec7 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerComponents.kt @@ -0,0 +1,126 @@ +/* + * 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.brushdesigner.ui + +import androidx.compose.foundation.layout.Arrangement +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.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.example.cahier.R +import com.godaddy.android.colorpicker.ClassicColorPicker +import com.godaddy.android.colorpicker.HsvColor + +/** + * A reusable slider control for brush property editing. + */ +@Composable +internal fun BrushSliderControl( + label: String, + value: Float, + valueRange: ClosedFloatingPointRange, + onValueChange: (Float) -> Unit +) { + Column(modifier = Modifier.padding(vertical = 8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(text = label, style = MaterialTheme.typography.bodyMedium) + Text(text = String.format(java.util.Locale.US, "%.2f", value), style = MaterialTheme.typography.bodySmall) + } + Slider( + value = value, + onValueChange = onValueChange, + valueRange = valueRange + ) + } +} + +/** + * A dialog for picking a custom color using a classic HSV color picker. + */ +@Composable +fun CustomColorPickerDialog( + initialColor: Color, + onColorSelected: (Color) -> Unit, + onDismissRequest: () -> Unit +) { + var currentColor by remember { mutableStateOf(HsvColor.from(initialColor)) } + + AlertDialog( + onDismissRequest = { + onColorSelected(currentColor.toColor()) + onDismissRequest() + }, + title = { Text(stringResource(R.string.brush_designer_pick_color)) }, + text = { + Column(horizontalAlignment = Alignment.End) { + ClassicColorPicker( + color = currentColor, + modifier = Modifier + .fillMaxWidth() + .height(300.dp), + onColorChanged = { hsvColor: HsvColor -> + currentColor = hsvColor + } + ) + Spacer(modifier = Modifier.height(16.dp)) + + val hexString = String.format(java.util.Locale.US, "%08x", currentColor.toColor().toArgb()) + Text( + text = hexString, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + confirmButton = { + TextButton( + onClick = { + onColorSelected(currentColor.toColor()) + onDismissRequest() + } + ) { + Text(stringResource(R.string.done)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(R.string.brush_designer_cancel)) + } + } + ) +} diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerScreen.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerScreen.kt new file mode 100644 index 0000000..7001c56 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerScreen.kt @@ -0,0 +1,657 @@ +/* + * 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.brushdesigner.ui + +import androidx.activity.compose.LocalActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.BottomSheetScaffold +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalMinimumInteractiveComponentSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SheetValue +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.VerticalDragHandle +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold +import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState +import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator +import androidx.compose.material3.rememberBottomSheetScaffoldState +import androidx.compose.material3.rememberStandardBottomSheetState +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass +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.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.ink.brush.ExperimentalInkCustomBrushApi +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.cahier.R +import com.example.cahier.developer.brushdesigner.viewmodel.BrushDesignerViewModel +import ink.proto.BrushTip as ProtoBrushTip + +/** + * Main entry point for the Brush Designer feature. + * This is the ONLY stateful composable — it owns the ViewModel reference + * and hoists all state/callbacks for child composables. + */ +@OptIn( + ExperimentalMaterial3Api::class, + ExperimentalMaterial3WindowSizeClassApi::class, + ExperimentalMaterial3AdaptiveApi::class, + ExperimentalInkCustomBrushApi::class +) +@Composable +fun BrushDesignerScreen( + onNavigateUp: () -> Unit, + viewModel: BrushDesignerViewModel = hiltViewModel() +) { + val activity = LocalActivity.current ?: return + val windowSizeClass = calculateWindowSizeClass(activity) + val isCompact = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact + + val exportLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("application/octet-stream") + ) { uri -> uri?.let { viewModel.saveBrushToFile(it) } } + + val importLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri -> uri?.let { viewModel.loadBrushFromFile(it) } } + + val navigator = rememberListDetailPaneScaffoldNavigator() + val paneExpansionState = rememberPaneExpansionState() + var hasSetInitialProportion by remember { mutableStateOf(false) } + + val savedBrushes by viewModel.savedPaletteBrushes.collectAsStateWithLifecycle() + val activeProto by viewModel.activeBrushProto.collectAsStateWithLifecycle() + val selectedCoatIndex by viewModel.selectedCoatIndex.collectAsStateWithLifecycle() + val activeBrush by viewModel.activeBrush.collectAsStateWithLifecycle() + val testStrokes by viewModel.testStrokes.collectAsStateWithLifecycle() + val brushColor by viewModel.brushColor.collectAsStateWithLifecycle() + val brushSize by viewModel.brushSize.collectAsStateWithLifecycle() + + LaunchedEffect(isCompact, hasSetInitialProportion) { + if (!isCompact && !hasSetInitialProportion) { + paneExpansionState.setFirstPaneProportion(0.35f) + hasSetInitialProportion = true + } + } + + var showSavePaletteDialog by remember { mutableStateOf(false) } + if (showSavePaletteDialog) { + SaveToPaletteDialog( + onSave = { name -> viewModel.saveToPalette(name) }, + onDismiss = { showSavePaletteDialog = false } + ) + } + + Scaffold( + topBar = { + BrushDesignerTopBar( + isCompact = isCompact, + onNavigateUp = onNavigateUp, + savedBrushes = savedBrushes, + onLoadStockBrush = { viewModel.loadStockBrush(it) }, + onLoadFromPalette = { viewModel.loadFromPalette(it) }, + onDeleteFromPalette = { viewModel.deleteFromPalette(it) }, + onClearCanvas = { viewModel.clearCanvas() }, + onShowSaveDialog = { showSavePaletteDialog = true }, + onImport = { + importLauncher.launch(arrayOf("application/octet-stream", "*/*")) + }, + onExport = { exportLauncher.launch("custom_brush.brush") } + ) + } + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + if (isCompact) { + val bottomSheetState = rememberBottomSheetScaffoldState( + bottomSheetState = rememberStandardBottomSheetState( + initialValue = SheetValue.PartiallyExpanded + ) + ) + BottomSheetScaffold( + scaffoldState = bottomSheetState, + sheetPeekHeight = 200.dp, + sheetContent = { + ControlsPane( + modifier = Modifier.fillMaxWidth(), + activeProto = activeProto, + selectedCoatIndex = selectedCoatIndex, + viewModel = viewModel + ) + } + ) { + PreviewPane( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + activeBrush = activeBrush, + activeProto = activeProto, + strokes = testStrokes, + brushColor = brushColor, + brushSize = brushSize, + onSetTextureStore = { viewModel.setTextureStore(it) }, + onReplaceStrokes = { viewModel.replaceStrokes(it) }, + onStrokesFinished = { viewModel.onStrokesFinished(it) }, + onGetNextBrush = { viewModel.getActiveBrush() ?: activeBrush!! }, + onSetBrushColor = { viewModel.setBrushColor(it) }, + onSetBrushSize = { viewModel.setBrushSize(it) } + ) + } + } else { + ListDetailPaneScaffold( + directive = navigator.scaffoldDirective, + value = navigator.scaffoldValue, + paneExpansionState = paneExpansionState, + paneExpansionDragHandle = { state -> + val interactionSource = remember { MutableInteractionSource() } + VerticalDragHandle( + modifier = Modifier.paneExpansionDraggable( + state, + LocalMinimumInteractiveComponentSize.current, + interactionSource, + ), + ) + }, + listPane = { + ControlsPane( + modifier = Modifier.fillMaxSize(), + activeProto = activeProto, + selectedCoatIndex = selectedCoatIndex, + viewModel = viewModel + ) + }, + detailPane = { + PreviewPane( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + activeBrush = activeBrush, + activeProto = activeProto, + strokes = testStrokes, + brushColor = brushColor, + brushSize = brushSize, + onSetTextureStore = { viewModel.setTextureStore(it) }, + onReplaceStrokes = { viewModel.replaceStrokes(it) }, + onStrokesFinished = { viewModel.onStrokesFinished(it) }, + onGetNextBrush = { viewModel.getActiveBrush() ?: activeBrush!! }, + onSetBrushColor = { viewModel.setBrushColor(it) }, + onSetBrushSize = { viewModel.setBrushSize(it) } + ) + } + ) + } + } + } +} + +/** + * The controls panel containing coat management, metadata fields, + * input model selector, and tabbed content (Tip Shape / Paint / Behaviors). + * + * Note: Still accepts ViewModel for delegation to tab content and input model + * sections. All state (activeProto, selectedCoatIndex) is hoisted from the screen. + */ +@OptIn(ExperimentalInkCustomBrushApi::class, ExperimentalMaterial3Api::class) +@Composable +private fun ControlsPane( + modifier: Modifier = Modifier, + activeProto: ink.proto.BrushFamily, + selectedCoatIndex: Int, + viewModel: BrushDesignerViewModel +) { + var textFieldsLocked by remember { mutableStateOf(false) } + var selectedTab by remember { mutableStateOf(BrushDesignerTab.TipShape) } + + val currentTip = activeProto + .coatsList.firstOrNull()?.tip ?: ProtoBrushTip.getDefaultInstance() + val inputModel = activeProto.inputModel + + var showTextureDialog by remember { mutableStateOf(false) } + var pendingTextureUri by remember { mutableStateOf(null) } + var textureIdInput by remember { mutableStateOf("") } + + val texturePickerLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.PickVisualMedia() + ) { uri -> + uri?.let { + pendingTextureUri = it + showTextureDialog = true + } + } + + if (showTextureDialog) { + TextureNameDialog( + textureIdInput = textureIdInput, + onTextureIdChange = { textureIdInput = it }, + onConfirm = { + if (textureIdInput.isNotBlank() && pendingTextureUri != null) { + viewModel.addCustomTexture(pendingTextureUri!!, textureIdInput) + showTextureDialog = false + textureIdInput = "" + } + }, + onDismiss = { showTextureDialog = false } + ) + } + + Column( + modifier = modifier + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + CoatLayersSection( + activeProto = activeProto, + selectedCoatIndex = selectedCoatIndex, + onSelectCoat = { viewModel.setSelectedCoat(it) }, + onAddCoat = { viewModel.addNewCoat() }, + onDeleteCoat = { viewModel.deleteSelectedCoat() } + ) + + HorizontalDivider() + Spacer(modifier = Modifier.height(8.dp)) + + MetadataSection( + clientId = activeProto.clientBrushFamilyId, + developerComment = activeProto.developerComment, + textFieldsLocked = textFieldsLocked, + onToggleLock = { textFieldsLocked = it }, + onClientIdChange = { viewModel.updateClientBrushFamilyId(it) }, + onCommentChange = { viewModel.updateDeveloperComment(it) } + ) + + HorizontalDivider() + + InputModelSection( + inputModel = inputModel, + onUpdateInputModelToSpring = { viewModel.updateInputModelToSpring() }, + onUpdateInputModelToNaive = { viewModel.updateInputModelToNaive() }, + onUpdateSlidingWindowModel = { ms, + hz -> + viewModel.updateSlidingWindowModel(ms, hz) + } + ) + + HorizontalDivider() + + TabRow(selectedTabIndex = selectedTab.ordinal) { + BrushDesignerTab.entries.forEach { tab -> + Tab( + selected = selectedTab == tab, + onClick = { selectedTab = tab }, + text = { Text(stringResource(tab.labelResId)) } + ) + } + } + + when (selectedTab) { + BrushDesignerTab.TipShape -> TipShapeTabContent( + currentTip = currentTip, + activeBrush = viewModel.getActiveBrush(), + textureStore = viewModel.getTextureStore(), + onUpdateTip = { block -> viewModel.updateTip(block) } + ) + + BrushDesignerTab.Paint -> PaintTabContent( + activeProto = activeProto, + selectedCoatIndex = selectedCoatIndex, + onUpdatePaintPreferences = { viewModel.updatePaintPreferences(it) }, + onUpdateSelfOverlap = { viewModel.updateSelfOverlap(it) }, + texturePickerLauncher = texturePickerLauncher, + getTextureBitmap = { viewModel.getTextureBitmap(it) } + ) + + BrushDesignerTab.Behaviors -> BehaviorsTabContent( + activeProto = activeProto, + selectedCoatIndex = selectedCoatIndex, + onUpdateBehaviors = { viewModel.updateBehaviorsList(it) }, + onClearBehaviors = { viewModel.clearBehaviors() }, + onAddBehavior = { nodes -> viewModel.addBehavior(nodes) }, + onAddSmoothedBehavior = { source, target, damping -> + viewModel.addSmoothedBehavior( + sourceType = source, + targetType = target, + dampingSeconds = damping + ) + }, + onAddJitterBehavior = { target -> + viewModel.addJitterBehavior(target) + } + ) + } + } +} + +@Composable +internal fun CoatLayersSection( + activeProto: ink.proto.BrushFamily, + selectedCoatIndex: Int, + onSelectCoat: (Int) -> Unit, + onAddCoat: () -> Unit, + onDeleteCoat: () -> Unit +) { + Text( + stringResource(R.string.brush_designer_brush_layers), + style = MaterialTheme.typography.titleMedium + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + activeProto.coatsList.forEachIndexed { index, _ -> + FilterChip( + selected = selectedCoatIndex == index, + onClick = { onSelectCoat(index) }, + label = { Text(stringResource(R.string.brush_designer_coat_label, index + 1)) } + ) + } + + IconButton(onClick = onAddCoat) { + Icon( + painterResource(R.drawable.add_24px), + contentDescription = stringResource(R.string.brush_designer_add_layer) + ) + } + + if (activeProto.coatsList.size > 1) { + IconButton(onClick = onDeleteCoat) { + Icon( + painterResource(R.drawable.delete_24px), + contentDescription = stringResource(R.string.brush_designer_delete_layer), + tint = MaterialTheme.colorScheme.error + ) + } + } + } +} + +@Composable +internal fun MetadataSection( + clientId: String, + developerComment: String, + textFieldsLocked: Boolean, + onToggleLock: (Boolean) -> Unit, + onClientIdChange: (String) -> Unit, + onCommentChange: (String) -> Unit +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Checkbox( + checked = textFieldsLocked, + onCheckedChange = onToggleLock + ) + Text( + stringResource(R.string.brush_designer_lock_fields), + style = MaterialTheme.typography.bodyMedium + ) + } + + OutlinedTextField( + value = clientId, + onValueChange = onClientIdChange, + label = { Text(stringResource(R.string.brush_designer_client_id)) }, + enabled = !textFieldsLocked, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + OutlinedTextField( + value = developerComment, + onValueChange = onCommentChange, + label = { Text(stringResource(R.string.brush_designer_developer_comment)) }, + enabled = !textFieldsLocked, + modifier = Modifier.fillMaxWidth(), + minLines = 3 + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun InputModelSection( + inputModel: ink.proto.BrushFamily.InputModel, + onUpdateInputModelToSpring: () -> Unit, + onUpdateInputModelToNaive: () -> Unit, + onUpdateSlidingWindowModel: (Long, Int) -> Unit +) { + Text( + stringResource(R.string.brush_designer_input_model), + style = MaterialTheme.typography.titleMedium + ) + + var expandedModelMenu by remember { mutableStateOf(false) } + val currentModelString = when { + inputModel.hasSpringModel() -> stringResource(R.string.brush_designer_spring_model) + inputModel.hasExperimentalNaiveModel() -> + stringResource(R.string.brush_designer_naive_model) + + inputModel.hasSlidingWindowModel() -> + stringResource(R.string.brush_designer_sliding_window) + + else -> stringResource(R.string.brush_designer_sliding_window_default) + } + + ExposedDropdownMenuBox( + expanded = expandedModelMenu, + onExpandedChange = { expandedModelMenu = it }, + ) { + OutlinedTextField( + value = currentModelString, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.brush_designer_model_type)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedModelMenu) + }, + modifier = Modifier + .menuAnchor() + .fillMaxWidth() + ) + + DropdownMenu( + expanded = expandedModelMenu, + onDismissRequest = { expandedModelMenu = false } + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.brush_designer_spring_model)) }, + onClick = { + onUpdateInputModelToSpring() + expandedModelMenu = false + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.brush_designer_naive_model)) }, + onClick = { + onUpdateInputModelToNaive() + expandedModelMenu = false + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.brush_designer_sliding_window)) }, + onClick = { + onUpdateSlidingWindowModel(20L, 180) + expandedModelMenu = false + } + ) + } + } + + if (inputModel.hasSlidingWindowModel() || (!inputModel.hasSpringModel() + && !inputModel.hasExperimentalNaiveModel()) + ) { + SlidingWindowControls( + inputModel = inputModel, + onUpdateSlidingWindowModel = onUpdateSlidingWindowModel + ) + } +} + +@Composable +internal fun SlidingWindowControls( + inputModel: ink.proto.BrushFamily.InputModel, + onUpdateSlidingWindowModel: (Long, Int) -> Unit +) { + val swModel = inputModel.slidingWindowModel + val windowMs = + if (swModel.hasWindowSizeSeconds()) (swModel.windowSizeSeconds * 1000) + .toLong() else 20L + val upsamplingHz = if (swModel.hasExperimentalUpsamplingPeriodSeconds()) { + val period = swModel.experimentalUpsamplingPeriodSeconds + if (period == Float.POSITIVE_INFINITY || period == 0f) 0 else (1f / period).toInt() + } else 180 + + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(12.dp)) { + BrushSliderControl( + label = stringResource(R.string.brush_designer_window_size_ms), + value = windowMs.toFloat(), + valueRange = 1f..100f, + onValueChange = { newValue -> + onUpdateSlidingWindowModel(newValue.toLong(), upsamplingHz) + } + ) + BrushSliderControl( + label = stringResource(R.string.brush_designer_upsampling_frequency_hz), + value = upsamplingHz.toFloat(), + valueRange = 0f..500f, + onValueChange = { newValue -> + onUpdateSlidingWindowModel(windowMs, newValue.toInt()) + } + ) + } + } +} + +@Composable +internal fun TextureNameDialog( + textureIdInput: String, + onTextureIdChange: (String) -> Unit, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.brush_designer_name_texture_title)) }, + text = { + OutlinedTextField( + value = textureIdInput, + onValueChange = onTextureIdChange, + label = { Text(stringResource(R.string.brush_designer_texture_id_hint)) }, + singleLine = true + ) + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(stringResource(R.string.brush_designer_load)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.brush_designer_cancel)) + } + } + ) +} + +@Preview(showBackground = true, widthDp = 360, heightDp = 200) +@Composable +private fun BrushSliderControlPreview() { + MaterialTheme { + Column(modifier = Modifier.padding(16.dp)) { + BrushSliderControl( + label = stringResource(R.string.brush_designer_scale_x), + value = 1.5f, + valueRange = 0.1f..5f, + onValueChange = {} + ) + } + } +} + +@Preview(showBackground = true, widthDp = 360, heightDp = 300) +@Composable +private fun TipShapeTabPreview() { + MaterialTheme { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + TipShapeTabContent( + currentTip = ProtoBrushTip.getDefaultInstance(), + activeBrush = null, + textureStore = null, + onUpdateTip = {} + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerTab.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerTab.kt new file mode 100644 index 0000000..cc8f606 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerTab.kt @@ -0,0 +1,29 @@ +/* + * 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.brushdesigner.ui + +import androidx.annotation.StringRes +import com.example.cahier.R + +/** + * Represents the three tabs in the Brush Designer controls' pane. + */ +enum class BrushDesignerTab(@param:StringRes val labelResId: Int) { + TipShape(R.string.brush_designer_tab_tip_shape), + Paint(R.string.brush_designer_tab_paint), + Behaviors(R.string.brush_designer_tab_behaviors); +} diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerTopBar.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerTopBar.kt new file mode 100644 index 0000000..b9f5fef --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerTopBar.kt @@ -0,0 +1,361 @@ +/* + * 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.brushdesigner.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +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.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.ink.brush.BrushFamily +import androidx.ink.brush.StockBrushes +import com.example.cahier.R +import com.example.cahier.developer.brushdesigner.data.CustomBrushEntity +import com.example.cahier.features.drawing.CustomBrushes + +/** + * Top app bar for the Brush Designer screen with stock brush, palette, + * save/import/export actions. + * + * Stateless: receives data and callbacks, does not access ViewModel. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun BrushDesignerTopBar( + isCompact: Boolean, + onNavigateUp: () -> Unit, + savedBrushes: List, + onLoadStockBrush: (BrushFamily) -> Unit, + onLoadFromPalette: (CustomBrushEntity) -> Unit, + onDeleteFromPalette: (String) -> Unit, + onClearCanvas: () -> Unit, + onShowSaveDialog: () -> Unit, + onImport: () -> Unit, + onExport: () -> Unit +) { + TopAppBar( + title = { + Text( + stringResource(R.string.brush_designer_title), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + navigationIcon = { + IconButton(onClick = onNavigateUp) { + Icon( + painterResource(R.drawable.arrow_back_24px), + contentDescription = stringResource(R.string.brush_designer_close), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + actions = { + BrushLibraryMenu( + onLoadStockBrush = onLoadStockBrush, + onLoadCahierBrush = onLoadStockBrush + ) + PaletteMenu( + savedBrushes = savedBrushes, + onLoadFromPalette = onLoadFromPalette, + onDeleteFromPalette = onDeleteFromPalette + ) + if (isCompact) { + OverflowMenu( + onShowSaveDialog = onShowSaveDialog, + onClearCanvas = onClearCanvas, + onImport = onImport, + onExport = onExport + ) + } else { + TextButton(onClick = onShowSaveDialog) { + Text(stringResource(R.string.brush_designer_save_to_palette)) + } + TextButton(onClick = onClearCanvas) { + Text(stringResource(R.string.clear)) + } + TextButton(onClick = onImport) { + Text(stringResource(R.string.brush_designer_import)) + } + TextButton(onClick = onExport) { + Text(stringResource(R.string.brush_designer_export)) + } + } + } + ) +} + +@Composable +private fun BrushLibraryMenu( + onLoadStockBrush: (BrushFamily) -> Unit, + onLoadCahierBrush: (BrushFamily) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + val context = LocalContext.current + val cahierBrushes = remember { CustomBrushes.getBrushes(context) } + + Box { + TextButton(onClick = { expanded = true }) { + Text(stringResource(R.string.brush_designer_brushes)) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem( + text = { + Text( + stringResource(R.string.brush_designer_section_stock), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + }, + onClick = {}, + enabled = false + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.highlighter)) }, + onClick = { + onLoadStockBrush(StockBrushes.highlighter()) + expanded = false + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.marker)) }, + onClick = { + onLoadStockBrush(StockBrushes.marker()) + expanded = false + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.pressure_pen)) }, + onClick = { + onLoadStockBrush(StockBrushes.pressurePen()) + expanded = false + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.dashed_line)) }, + onClick = { + onLoadStockBrush(StockBrushes.dashedLine()) + expanded = false + } + ) + + if (cahierBrushes.isNotEmpty()) { + DropdownMenuItem( + text = { + Text( + stringResource(R.string.brush_designer_section_cahier), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + }, + onClick = {}, + enabled = false + ) + cahierBrushes.forEach { customBrush -> + DropdownMenuItem( + text = { Text(customBrush.name) }, + onClick = { + onLoadCahierBrush(customBrush.brushFamily) + expanded = false + } + ) + } + } + } + } +} + +@Composable +private fun PaletteMenu( + savedBrushes: List, + onLoadFromPalette: (CustomBrushEntity) -> Unit, + onDeleteFromPalette: (String) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + Box { + TextButton(onClick = { expanded = true }) { + Text(stringResource(R.string.brush_designer_my_palette)) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + if (savedBrushes.isEmpty()) { + DropdownMenuItem( + text = { Text(stringResource(R.string.brush_designer_no_saved_brushes)) }, + onClick = { expanded = false } + ) + } 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 = { + onDeleteFromPalette(entity.name) + }) { + Icon( + painterResource(R.drawable.delete_24px), + contentDescription = stringResource(R.string.delete), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(18.dp) + ) + } + } + }, + onClick = { + onLoadFromPalette(entity) + expanded = false + } + ) + } + } + } + } +} + +@Composable +private fun OverflowMenu( + onShowSaveDialog: () -> Unit, + onClearCanvas: () -> Unit, + onImport: () -> Unit, + onExport: () -> Unit +) { + var expanded by remember { mutableStateOf(false) } + Box { + IconButton(onClick = { expanded = true }) { + Icon( + painterResource(R.drawable.menu_24px), + contentDescription = stringResource(R.string.brush_designer_more_options), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.brush_designer_save_to_palette)) }, + onClick = { onShowSaveDialog(); expanded = false } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.clear)) }, + onClick = { onClearCanvas(); expanded = false } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.brush_designer_import)) }, + onClick = { onImport(); expanded = false } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.brush_designer_export)) }, + onClick = { onExport(); expanded = false } + ) + } + } +} + +/** + * Dialog for saving the current brush to the local palette with a name. + * + * Stateless: receives callbacks only. + */ +@Composable +internal fun SaveToPaletteDialog( + onSave: (String) -> Unit, + onDismiss: () -> Unit +) { + var brushNameInput by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.brush_designer_save_dialog_title)) }, + text = { + androidx.compose.foundation.layout.Column( + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Surface( + color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.4f), + shape = RoundedCornerShape(8.dp) + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painterResource(R.drawable.info_24px), + contentDescription = null, + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.width(12.dp)) + Text( + text = stringResource(R.string.brush_designer_save_dialog_info), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + } + OutlinedTextField( + value = brushNameInput, + onValueChange = { brushNameInput = it }, + label = { Text(stringResource(R.string.brush_designer_brush_name)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + }, + confirmButton = { + TextButton(onClick = { + if (brushNameInput.isNotBlank()) { + onSave(brushNameInput) + onDismiss() + } + }) { Text(stringResource(R.string.save)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.brush_designer_cancel)) + } + } + ) +} diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/EditableListWidget.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/EditableListWidget.kt new file mode 100644 index 0000000..b6878ed --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/EditableListWidget.kt @@ -0,0 +1,224 @@ +/* + * 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.brushdesigner.ui + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +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.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.example.cahier.R + +/** + * Wrapper for A/B testing: tracks whether each item is enabled or disabled + * without removing it from the list. + */ +data class CheckableItem(val item: T, val enabled: Boolean = true) + +/** + * A generic list editor that supports add, delete, duplicate, and A/B + * testing (enable/disable toggle) for ordered lists of proto items. + * + * Stateless: the canonical item list is passed in via [items], and all + * changes are emitted via [onItemsChanged] with only the enabled items. + * + * @param title section header text + * @param items current list of items (from proto) + * @param defaultItem factory default for new items + * @param onItemsChanged callback with the updated full list of items + * @param itemHeader display label for each item in collapsed view + * @param editorContent expanded editor for the selected item + */ +@Composable +internal fun EditableListWidget( + title: String, + items: List, + defaultItem: T, + onItemsChanged: (List) -> Unit, + itemHeader: @Composable (T) -> String, + editorContent: @Composable (item: T, onItemChanged: (T) -> Unit) -> Unit +) { + var itemStates by remember { mutableStateOf(items + .map { CheckableItem(it, true) }) } + + + if (itemStates.size != items.size || + itemStates.zip(items).any { (s, i) -> s.item != i }) { + itemStates = items.mapIndexed { index, item -> + CheckableItem(item, itemStates + .getOrNull(index)?.enabled ?: true) + } + } + var selectedIndex by remember { mutableStateOf(null) } + + fun emitEnabledItems() { + onItemsChanged(itemStates.filter { it.enabled }.map { it.item }) + } + + Column(modifier = Modifier.fillMaxWidth()) { + if (title.isNotEmpty()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(title, style = MaterialTheme.typography.titleMedium) + IconButton(onClick = { + itemStates = itemStates + CheckableItem(defaultItem, true) + selectedIndex = itemStates.size + emitEnabledItems() + }) { + Icon( + painterResource(R.drawable.add_24px), + contentDescription = stringResource( + R.string.brush_designer_add_item, title + ), + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + + itemStates.forEachIndexed { index, state -> + val isSelected = selectedIndex == index + Card( + colors = CardDefaults.cardColors( + containerColor = if (isSelected) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + } + ), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + onClick = { selectedIndex = if (isSelected) null else index } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = state.enabled, + onCheckedChange = { checked -> + val newList = itemStates.toMutableList() + newList[index] = state.copy(enabled = checked) + itemStates = newList + emitEnabledItems() + } + ) + + Text( + text = itemHeader(state.item), + modifier = Modifier.weight(1f), + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodyMedium + ) + + IconButton(onClick = { + val newList = itemStates.toMutableList() + newList.add(index + 1, state.copy()) + itemStates = newList + emitEnabledItems() + }) { + Icon( + painterResource(R.drawable.content_copy_24px), + contentDescription = stringResource( + R.string.brush_designer_duplicate_item + ) + ) + } + + IconButton(onClick = { + val newList = itemStates.toMutableList() + newList.removeAt(index) + itemStates = newList + if (selectedIndex == index) selectedIndex = null + else if (selectedIndex != null && selectedIndex!! > index) { + selectedIndex = selectedIndex!! - 1 + } + emitEnabledItems() + }) { + Icon( + painterResource(R.drawable.delete_24px), + contentDescription = stringResource( + R.string.brush_designer_delete_item + ), + tint = MaterialTheme.colorScheme.error + ) + } + } + } + } + + selectedIndex?.let { index -> + if (index < itemStates.size) { + val currentItem = itemStates[index].item + Card( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + border = BorderStroke( + 1.dp, MaterialTheme.colorScheme.outlineVariant + ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource( + R.string.brush_designer_editing_item, index + 1 + ), + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.labelLarge + ) + Spacer(Modifier.height(8.dp)) + + editorContent(currentItem) { updatedItem -> + val newList = itemStates.toMutableList() + newList[index] = itemStates[index].copy(item = updatedItem) + itemStates = newList + emitEnabledItems() + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/MathCurvePreview.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/MathCurvePreview.kt new file mode 100644 index 0000000..be13543 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/MathCurvePreview.kt @@ -0,0 +1,158 @@ +/* + * 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.brushdesigner.ui + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.dp +import ink.proto.BrushBehavior + +/** + * A Canvas-based composable that visually plots the easing/response function + * of a [BrushBehavior.ResponseNode]. + * + * Supports: + * - Cubic Bézier curves (cubicBezierResponseCurve) + * - Linear piecewise curves (linearResponseCurve) + * - Predefined easing functions (rendered as a straight line placeholder) + * - Steps response curves (rendered as a staircase) + */ +@Composable +internal fun MathCurvePreview( + responseNode: BrushBehavior.ResponseNode, + modifier: Modifier = Modifier +) { + val lineColor = MaterialTheme.colorScheme.primary + val gridColor = MaterialTheme.colorScheme.outlineVariant + + Card( + modifier = modifier + .fillMaxWidth() + .height(150.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer + ) + ) { + Canvas( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + val w = size.width + val h = size.height + + drawLine( + gridColor, + start = Offset(0f, h), + end = Offset(w, h), + strokeWidth = 2f + ) + drawLine( + gridColor, + start = Offset(0f, 0f), + end = Offset(0f, h), + strokeWidth = 2f + ) + + for (i in 1..3) { + val frac = i / 4f + drawLine( + gridColor.copy(alpha = 0.3f), + start = Offset(0f, h * (1f - frac)), + end = Offset(w, h * (1f - frac)), + strokeWidth = 1f + ) + drawLine( + gridColor.copy(alpha = 0.3f), + start = Offset(w * frac, 0f), + end = Offset(w * frac, h), + strokeWidth = 1f + ) + } + + val path = Path() + val curveCase = responseNode.responseCurveCase + + when (curveCase) { + BrushBehavior.ResponseNode.ResponseCurveCase.CUBIC_BEZIER_RESPONSE_CURVE -> { + val cb = responseNode.cubicBezierResponseCurve + path.moveTo(0f, h) + path.cubicTo( + cb.x1 * w, h - (cb.y1 * h), + cb.x2 * w, h - (cb.y2 * h), + w, 0f + ) + } + + BrushBehavior.ResponseNode.ResponseCurveCase.LINEAR_RESPONSE_CURVE -> { + val linear = responseNode.linearResponseCurve + val xs = linear.xList + val ys = linear.yList + if (xs.isNotEmpty() && ys.isNotEmpty()) { + path.moveTo(xs[0] * w, h - (ys[0] * h)) + for (i in 1 until minOf(xs.size, ys.size)) { + path.lineTo(xs[i] * w, h - (ys[i] * h)) + } + } else { + path.moveTo(0f, h) + path.lineTo(w, 0f) + } + } + + BrushBehavior.ResponseNode.ResponseCurveCase.STEPS_RESPONSE_CURVE -> { + val steps = responseNode.stepsResponseCurve + val stepCount = steps.stepCount + if (stepCount > 0) { + val stepWidth = w / stepCount + val stepHeight = h / stepCount + path.moveTo(0f, h) + for (i in 0 until stepCount) { + val y = h - ((i + 1) * stepHeight) + path.lineTo(i * stepWidth, y) + path.lineTo((i + 1) * stepWidth, y) + } + } else { + path.moveTo(0f, h) + path.lineTo(w, 0f) + } + } + + else -> { + path.moveTo(0f, h) + path.lineTo(w, 0f) + } + } + + drawPath( + path, + color = lineColor, + style = Stroke(width = 4f) + ) + } + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/NodeEditors.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/NodeEditors.kt new file mode 100644 index 0000000..cdde9db --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/NodeEditors.kt @@ -0,0 +1,535 @@ +/* + * 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.brushdesigner.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.example.cahier.R +import ink.proto.BrushBehavior + +/** + * Dispatches to the correct editor based on the [BrushBehavior.Node.NodeCase]. + * + * Each editor is stateless: it receives the current node and emits + * the updated node via [onNodeChanged]. + */ +@Composable +internal fun NodeEditor( + node: BrushBehavior.Node, + onNodeChanged: (BrushBehavior.Node) -> Unit +) { + when (node.nodeCase) { + BrushBehavior.Node.NodeCase.SOURCE_NODE -> + SourceNodeEditor(node.sourceNode, onNodeChanged) + + BrushBehavior.Node.NodeCase.RESPONSE_NODE -> + ResponseNodeEditor(node.responseNode, onNodeChanged) + + BrushBehavior.Node.NodeCase.DAMPING_NODE -> + DampingNodeEditor(node.dampingNode, onNodeChanged) + + BrushBehavior.Node.NodeCase.NOISE_NODE -> + NoiseNodeEditor(node.noiseNode, onNodeChanged) + + BrushBehavior.Node.NodeCase.TARGET_NODE -> + TargetNodeEditor(node.targetNode, onNodeChanged) + + BrushBehavior.Node.NodeCase.CONSTANT_NODE -> + ConstantNodeEditor(node.constantNode, onNodeChanged) + + BrushBehavior.Node.NodeCase.BINARY_OP_NODE -> + BinaryOpNodeEditor(node.binaryOpNode, onNodeChanged) + + BrushBehavior.Node.NodeCase.INTERPOLATION_NODE -> + InterpolationNodeEditor(node.interpolationNode, onNodeChanged) + + else -> Text( + "Unsupported node type: ${node.nodeCase}", + style = MaterialTheme.typography.bodySmall + ) + } +} + +@Composable +internal fun SourceNodeEditor( + source: BrushBehavior.SourceNode, + onNodeChanged: (BrushBehavior.Node) -> Unit +) { + Column { + Text(stringResource(R.string.brush_designer_node_source), style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.secondary) + + val sources = BrushBehavior.Source.entries.filter { + it != BrushBehavior.Source.SOURCE_UNSPECIFIED + } + EnumDropdown( + label = stringResource(R.string.brush_designer_node_source_input), + currentValue = source.source, + values = sources, + displayName = { it.name.replace("SOURCE_", "") }, + onSelected = { selected -> + onNodeChanged( + BrushBehavior.Node.newBuilder().setSourceNode( + source.toBuilder().setSource(selected) + ).build() + ) + } + ) + + NumericField( + title = stringResource(R.string.brush_designer_node_range_start), + value = source.sourceValueRangeStart, + limits = NumericLimits.standard(-100f, 100f, 0.1f), + onValueChanged = { + onNodeChanged( + BrushBehavior.Node.newBuilder().setSourceNode( + source.toBuilder().setSourceValueRangeStart(it) + ).build() + ) + } + ) + + NumericField( + title = stringResource(R.string.brush_designer_node_range_end), + value = source.sourceValueRangeEnd, + limits = NumericLimits.standard(-100f, 100f, 0.1f), + onValueChanged = { + onNodeChanged( + BrushBehavior.Node.newBuilder().setSourceNode( + source.toBuilder().setSourceValueRangeEnd(it) + ).build() + ) + } + ) + + EnumDropdown( + label = stringResource(R.string.brush_designer_node_out_of_range), + currentValue = source.sourceOutOfRangeBehavior, + values = BrushBehavior.OutOfRange.entries.filter { + it != BrushBehavior.OutOfRange.OUT_OF_RANGE_UNSPECIFIED + }, + displayName = { it.name.replace("OUT_OF_RANGE_", "") }, + onSelected = { selected -> + onNodeChanged( + BrushBehavior.Node.newBuilder().setSourceNode( + source.toBuilder().setSourceOutOfRangeBehavior(selected) + ).build() + ) + } + ) + } +} + +@Composable +internal fun ResponseNodeEditor( + response: BrushBehavior.ResponseNode, + onNodeChanged: (BrushBehavior.Node) -> Unit +) { + Column { + Text(stringResource(R.string.brush_designer_node_response_curve), style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.secondary) + + MathCurvePreview( + responseNode = response, + modifier = Modifier.padding(vertical = 8.dp) + ) + + val currentType = when (response.responseCurveCase) { + BrushBehavior.ResponseNode.ResponseCurveCase.CUBIC_BEZIER_RESPONSE_CURVE -> + ResponseCurveType.CubicBezier + BrushBehavior.ResponseNode.ResponseCurveCase.LINEAR_RESPONSE_CURVE -> + ResponseCurveType.Linear + BrushBehavior.ResponseNode.ResponseCurveCase.STEPS_RESPONSE_CURVE -> + ResponseCurveType.Steps + else -> ResponseCurveType.Predefined + } + + EnumDropdown( + label = stringResource(R.string.brush_designer_node_curve_type), + currentValue = currentType, + values = ResponseCurveType.entries.toList(), + displayName = { it.displayName }, + onSelected = { selected -> + val newResponseBuilder = response.toBuilder() + when (selected) { + ResponseCurveType.Predefined -> + newResponseBuilder.setPredefinedResponseCurve( + ink.proto.PredefinedEasingFunction.PREDEFINED_EASING_EASE + ) + ResponseCurveType.CubicBezier -> + newResponseBuilder.setCubicBezierResponseCurve( + ink.proto.CubicBezierEasingFunction.newBuilder() + .setX1(0.25f).setY1(0.1f).setX2(0.25f).setY2(1f) + ) + ResponseCurveType.Linear -> + newResponseBuilder.setLinearResponseCurve( + ink.proto.LinearEasingFunction.newBuilder() + ) + ResponseCurveType.Steps -> + newResponseBuilder.setStepsResponseCurve( + ink.proto.StepsEasingFunction.newBuilder().setStepCount(4) + ) + } + onNodeChanged( + BrushBehavior.Node.newBuilder() + .setResponseNode(newResponseBuilder).build() + ) + } + ) + + when (response.responseCurveCase) { + BrushBehavior.ResponseNode.ResponseCurveCase.CUBIC_BEZIER_RESPONSE_CURVE -> { + val cb = response.cubicBezierResponseCurve + NumericField( + title = stringResource(R.string.brush_designer_node_control_x1), + value = cb.x1, + limits = NumericLimits.standard(0f, 1f, 0.05f) + ) { + onNodeChanged( + BrushBehavior.Node.newBuilder().setResponseNode( + response.toBuilder().setCubicBezierResponseCurve( + cb.toBuilder().setX1(it) + ) + ).build() + ) + } + NumericField( + title = stringResource(R.string.brush_designer_node_control_y1), + value = cb.y1, + limits = NumericLimits.standard(-1f, 2f, 0.05f) + ) { + onNodeChanged( + BrushBehavior.Node.newBuilder().setResponseNode( + response.toBuilder().setCubicBezierResponseCurve( + cb.toBuilder().setY1(it) + ) + ).build() + ) + } + NumericField( + title = stringResource(R.string.brush_designer_node_control_x2), + value = cb.x2, + limits = NumericLimits.standard(0f, 1f, 0.05f) + ) { + onNodeChanged( + BrushBehavior.Node.newBuilder().setResponseNode( + response.toBuilder().setCubicBezierResponseCurve( + cb.toBuilder().setX2(it) + ) + ).build() + ) + } + NumericField( + title = stringResource(R.string.brush_designer_node_control_y2), + value = cb.y2, + limits = NumericLimits.standard(-1f, 2f, 0.05f) + ) { + onNodeChanged( + BrushBehavior.Node.newBuilder().setResponseNode( + response.toBuilder().setCubicBezierResponseCurve( + cb.toBuilder().setY2(it) + ) + ).build() + ) + } + } + + BrushBehavior.ResponseNode.ResponseCurveCase.PREDEFINED_RESPONSE_CURVE -> { + EnumDropdown( + label = stringResource(R.string.brush_designer_node_predefined_curve), + currentValue = response.predefinedResponseCurve, + values = ink.proto.PredefinedEasingFunction.entries.toList(), + displayName = { + it.name.replace("PREDEFINED_EASING_FUNCTION_", "") + }, + onSelected = { selected -> + onNodeChanged( + BrushBehavior.Node.newBuilder().setResponseNode( + response.toBuilder().setPredefinedResponseCurve(selected) + ).build() + ) + } + ) + } + + BrushBehavior.ResponseNode.ResponseCurveCase.STEPS_RESPONSE_CURVE -> { + val steps = response.stepsResponseCurve + NumericField( + title = stringResource(R.string.brush_designer_node_step_count), + value = steps.stepCount.toFloat(), + limits = NumericLimits.standard(1f, 20f, 1f) + ) { + onNodeChanged( + BrushBehavior.Node.newBuilder().setResponseNode( + response.toBuilder().setStepsResponseCurve( + steps.toBuilder().setStepCount(it.toInt()) + ) + ).build() + ) + } + } + + else -> { + Text( + "Linear curves: edit points via export/import.", + style = MaterialTheme.typography.bodySmall + ) + } + } + } +} + +@Composable +internal fun DampingNodeEditor( + damping: BrushBehavior.DampingNode, + onNodeChanged: (BrushBehavior.Node) -> Unit +) { + Column { + Text(stringResource(R.string.brush_designer_node_damping), style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.secondary) + + EnumDropdown( + label = stringResource(R.string.brush_designer_node_damping_source), + currentValue = damping.dampingSource, + values = BrushBehavior.ProgressDomain.entries.filter { + it != BrushBehavior.ProgressDomain.PROGRESS_DOMAIN_UNSPECIFIED + }, + displayName = { it.name.replace("PROGRESS_DOMAIN_", "") }, + onSelected = { selected -> + onNodeChanged( + BrushBehavior.Node.newBuilder().setDampingNode( + damping.toBuilder().setDampingSource(selected) + ).build() + ) + } + ) + + NumericField( + title = stringResource(R.string.brush_designer_node_gap_seconds), + value = damping.dampingGap, + limits = NumericLimits.standard(0.01f, 2f, 0.01f), + onValueChanged = { + onNodeChanged( + BrushBehavior.Node.newBuilder().setDampingNode( + damping.toBuilder().setDampingGap(it) + ).build() + ) + } + ) + } +} + +@Composable +internal fun NoiseNodeEditor( + noise: BrushBehavior.NoiseNode, + onNodeChanged: (BrushBehavior.Node) -> Unit +) { + Column { + Text( + text = stringResource(R.string.brush_designer_node_noise), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.secondary + ) + + NumericField( + title = stringResource(R.string.brush_designer_node_seed), + value = noise.seed.toFloat(), + limits = NumericLimits.standard(-10000f, 10000f, 1f), + onValueChanged = { + onNodeChanged( + BrushBehavior.Node.newBuilder().setNoiseNode( + noise.toBuilder().setSeed(it.toInt()) + ).build() + ) + } + ) + + EnumDropdown( + label = stringResource(R.string.brush_designer_node_vary_over), + currentValue = noise.varyOver, + values = BrushBehavior.ProgressDomain.entries.filter { + it != BrushBehavior.ProgressDomain.PROGRESS_DOMAIN_UNSPECIFIED + }, + displayName = { it.name.replace("PROGRESS_DOMAIN_", "") }, + onSelected = { selected -> + onNodeChanged( + BrushBehavior.Node.newBuilder().setNoiseNode( + noise.toBuilder().setVaryOver(selected) + ).build() + ) + } + ) + + NumericField( + title = stringResource(R.string.brush_designer_node_base_period), + value = noise.basePeriod, + limits = NumericLimits.standard(0.01f, 5f, 0.01f), + onValueChanged = { + onNodeChanged( + BrushBehavior.Node.newBuilder().setNoiseNode( + noise.toBuilder().setBasePeriod(it) + ).build() + ) + } + ) + } +} + +@Composable +internal fun TargetNodeEditor( + target: BrushBehavior.TargetNode, + onNodeChanged: (BrushBehavior.Node) -> Unit +) { + Column { + Text(stringResource(R.string.brush_designer_node_target), style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.secondary) + + EnumDropdown( + label = stringResource(R.string.brush_designer_node_target_output), + currentValue = target.target, + values = BrushBehavior.Target.entries.filter { + it != BrushBehavior.Target.TARGET_UNSPECIFIED + }, + displayName = { it.name.replace("TARGET_", "") }, + onSelected = { selected -> + onNodeChanged( + BrushBehavior.Node.newBuilder().setTargetNode( + target.toBuilder().setTarget(selected) + ).build() + ) + } + ) + + NumericField( + title = stringResource(R.string.brush_designer_node_modifier_range_start), + value = target.targetModifierRangeStart, + limits = NumericLimits.standard(-10f, 10f, 0.05f), + onValueChanged = { + onNodeChanged( + BrushBehavior.Node.newBuilder().setTargetNode( + target.toBuilder().setTargetModifierRangeStart(it) + ).build() + ) + } + ) + + NumericField( + title = stringResource(R.string.brush_designer_node_modifier_range_end), + value = target.targetModifierRangeEnd, + limits = NumericLimits.standard(-10f, 10f, 0.05f), + onValueChanged = { + onNodeChanged( + BrushBehavior.Node.newBuilder().setTargetNode( + target.toBuilder().setTargetModifierRangeEnd(it) + ).build() + ) + } + ) + } +} + +@Composable +internal fun ConstantNodeEditor( + constant: BrushBehavior.ConstantNode, + onNodeChanged: (BrushBehavior.Node) -> Unit +) { + Column { + Text(stringResource(R.string.brush_designer_node_constant_value), style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.secondary) + + NumericField( + title = stringResource(R.string.brush_designer_node_value), + value = constant.value, + limits = NumericLimits.standard(-100f, 100f, 0.1f), + onValueChanged = { + onNodeChanged( + BrushBehavior.Node.newBuilder().setConstantNode( + constant.toBuilder().setValue(it) + ).build() + ) + } + ) + } +} + +@Composable +internal fun BinaryOpNodeEditor( + binaryOp: BrushBehavior.BinaryOpNode, + onNodeChanged: (BrushBehavior.Node) -> Unit +) { + Column { + Text(stringResource(R.string.brush_designer_node_binary_operation), style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.secondary) + + EnumDropdown( + label = stringResource(R.string.brush_designer_node_operation), + currentValue = binaryOp.operation, + values = BrushBehavior.BinaryOp.entries.filter { + it != BrushBehavior.BinaryOp.BINARY_OP_UNSPECIFIED + }, + displayName = { it.name.replace("BINARY_OP_", "") }, + onSelected = { selected -> + onNodeChanged( + BrushBehavior.Node.newBuilder().setBinaryOpNode( + binaryOp.toBuilder().setOperation(selected) + ).build() + ) + } + ) + } +} + +@Composable +internal fun InterpolationNodeEditor( + interpolation: BrushBehavior.InterpolationNode, + onNodeChanged: (BrushBehavior.Node) -> Unit +) { + Column { + Text(stringResource(R.string.brush_designer_node_interpolation), style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.secondary) + + EnumDropdown( + label = stringResource(R.string.brush_designer_node_interpolation), + currentValue = interpolation.interpolation, + values = BrushBehavior.Interpolation.entries.filter { + it != BrushBehavior.Interpolation.INTERPOLATION_UNSPECIFIED + }, + displayName = { it.name.replace("INTERPOLATION_", "") }, + onSelected = { selected -> + onNodeChanged( + BrushBehavior.Node.newBuilder().setInterpolationNode( + interpolation.toBuilder().setInterpolation(selected) + ).build() + ) + } + ) + } +} + +/** Response curve types available in the curve type selector dropdown. */ +private enum class ResponseCurveType(val displayName: String) { + Predefined("Predefined"), + CubicBezier("Cubic Bézier"), + Linear("Linear"), + Steps("Steps"); +} diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/NumericField.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/NumericField.kt new file mode 100644 index 0000000..6f24a30 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/NumericField.kt @@ -0,0 +1,263 @@ +/* + * 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.brushdesigner.ui + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.example.cahier.R +import kotlin.math.PI + +/** + * Defines display limits and unit conversion for numeric fields. + * + * @param min minimum display value + * @param max maximum display value + * @param step increment/decrement step in display units + * @param displayUnit suffix shown after the value (e.g. "°", "%") + * @param unitScale multiplier to convert real value → display value + */ +class NumericLimits( + val min: Float, + val max: Float, + val step: Float, + val displayUnit: String = "", + val unitScale: Float = 1.0f +) { + /** Convert a display value back to the real (proto) value. */ + fun toRealValue(displayValue: Float): Float = displayValue / unitScale + + /** Convert a real (proto) value to the display value. */ + fun fromRealValue(realValue: Float): Float = realValue * unitScale + + /** Format a display value with its unit suffix. */ + fun format(displayValue: Float): String { + val formatted = if (displayValue == displayValue.toLong().toFloat()) { + displayValue.toLong().toString() + } else { + String.format(java.util.Locale.US, "%.2f", displayValue) + } + return "$formatted$displayUnit" + } + + companion object { + /** Values stored as 0..1 shown as 0..100% */ + fun floatShownAsPercent( + minPercent: Float = 0f, + maxPercent: Float = 100f + ): NumericLimits = + NumericLimits( + min = minPercent, + max = maxPercent, + step = 1f, + displayUnit = "%", + unitScale = 100f + ) + + /** Values stored in radians, shown in degrees */ + fun radiansShownAsDegrees( + minDegrees: Float, + maxDegrees: Float + ): NumericLimits = + NumericLimits( + min = minDegrees, + max = maxDegrees, + step = 1f, + displayUnit = "°", + unitScale = (180f / PI.toFloat()) + ) + + /** Raw numeric values with a configurable step */ + fun standard( + min: Float, + max: Float, + step: Float = 0.01f + ): NumericLimits = + NumericLimits( + min = min, + max = max, + step = step + ) + } +} + +/** + * A professional numeric input with a slider, ± buttons, and a long-press + * dialog for exact value entry. + * + * Stateless: all state is passed in via [value] and changes are emitted + * via [onValueChanged] in real (proto) units. + * + * @param title label displayed above the slider + * @param value current value in real (proto) units + * @param limits display range, step, and unit conversion + * @param onValueChanged callback with the new value in real (proto) units + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun NumericField( + title: String, + value: Float, + limits: NumericLimits, + onValueChanged: (Float) -> Unit +) { + val displayValue = limits.fromRealValue(value) + var showTextInput by remember { mutableStateOf(false) } + var textInputValue by remember { mutableStateOf("") } + + Column(modifier = Modifier.padding(vertical = 4.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .combinedClickable( + onLongClick = { + textInputValue = displayValue.toString() + showTextInput = true + }, + onClick = {} + ) + ) + Text( + text = limits.format(displayValue), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .combinedClickable( + onClick = { + textInputValue = displayValue.toString() + showTextInput = true + }, + onLongClick = { + textInputValue = displayValue.toString() + showTextInput = true + } + ) + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = { + val newVal = (displayValue - limits.step).coerceAtLeast(limits.min) + onValueChanged(limits.toRealValue(newVal)) + }) { + Icon( + painterResource(R.drawable.remove_24px), + contentDescription = stringResource( + R.string.brush_designer_decrease, title + ), + tint = MaterialTheme.colorScheme.primary + ) + } + + Slider( + value = displayValue.coerceIn(limits.min, limits.max), + onValueChange = { onValueChanged(limits.toRealValue(it)) }, + valueRange = limits.min..limits.max, + modifier = Modifier.weight(1f) + ) + + IconButton(onClick = { + val newVal = (displayValue + limits.step).coerceAtMost(limits.max) + onValueChanged(limits.toRealValue(newVal)) + }) { + Icon( + painterResource(R.drawable.add_24px), + contentDescription = stringResource( + R.string.brush_designer_increase, title + ), + tint = MaterialTheme.colorScheme.primary + ) + } + } + + if (showTextInput) { + AlertDialog( + onDismissRequest = { showTextInput = false }, + title = { Text(stringResource(R.string.brush_designer_enter_value)) }, + text = { + OutlinedTextField( + value = textInputValue, + onValueChange = { textInputValue = it }, + label = { + Text( + "$title (${ + limits.displayUnit.ifEmpty { + stringResource(R.string.brush_designer_value_label) + } + })" + ) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Decimal + ), + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { + TextButton(onClick = { + textInputValue.toFloatOrNull()?.let { parsed -> + val clamped = parsed.coerceIn(limits.min, limits.max) + onValueChanged(limits.toRealValue(clamped)) + } + showTextInput = false + }) { + Text(stringResource(R.string.done)) + } + }, + dismissButton = { + TextButton(onClick = { showTextInput = false }) { + Text(stringResource(R.string.brush_designer_cancel)) + } + } + ) + } + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/PaintTab.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/PaintTab.kt new file mode 100644 index 0000000..7ef024b --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/PaintTab.kt @@ -0,0 +1,664 @@ +/* + * 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.brushdesigner.ui + +import android.graphics.Bitmap +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +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.size +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +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.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.example.cahier.R +import ink.proto.BrushFamily as ProtoBrushFamily +import ink.proto.BrushPaint as ProtoBrushPaint +import ink.proto.ColorFunction as ProtoColorFunction + +/** + * Tab 1: Paint & texture controls — multi-layer textures, multi-function colors, + * self overlap, and texture import. + * + * Uses [EditableListWidget] for managing multiple texture layers and color + * functions with add/remove/duplicate/toggle support. + * + * Stateless: receives data and callbacks, does not access ViewModel. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun PaintTabContent( + activeProto: ProtoBrushFamily, + selectedCoatIndex: Int, + onUpdatePaintPreferences: (List) -> Unit, + onUpdateSelfOverlap: (ProtoBrushPaint.SelfOverlap) -> Unit, + texturePickerLauncher: androidx.activity.result.ActivityResultLauncher< + androidx.activity.result.PickVisualMediaRequest>, + getTextureBitmap: (String) -> Bitmap? +) { + val paintPrefs = + activeProto.coatsList.getOrNull(selectedCoatIndex)?.paintPreferencesList + ?: emptyList() + + val availableTextures = activeProto.textureIdToBitmapMap.keys.toList() + + Text( + text = stringResource(R.string.brush_designer_paint_texture), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 8.dp) + ) + + EditableListWidget( + title = stringResource(R.string.brush_designer_paint_preferences), + items = paintPrefs, + defaultItem = ProtoBrushPaint.newBuilder() + .addTextureLayers( + ProtoBrushPaint.TextureLayer.newBuilder() + .setSizeUnit(ProtoBrushPaint.TextureLayer.SizeUnit.SIZE_UNIT_BRUSH_SIZE) + .setSizeX(1f).setSizeY(1f) + .setMapping(ProtoBrushPaint.TextureLayer.Mapping.MAPPING_TILING) + .setBlendMode(ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_OVER) + ) + .build(), + onItemsChanged = onUpdatePaintPreferences, + itemHeader = { paint -> + val texCount = paint.textureLayersCount + val colorCount = paint.colorFunctionsCount + "$texCount texture(s), $colorCount color fn(s)" + }, + editorContent = { paint, onPaintChanged -> + PaintPreferenceEditor( + paint = paint, + availableTextures = availableTextures, + onPaintChanged = onPaintChanged, + getTextureBitmap = getTextureBitmap + ) + } + ) + + Spacer(modifier = Modifier.height(16.dp)) + + SelfOverlapSelector( + currentPaint = paintPrefs.firstOrNull() ?: ProtoBrushPaint.getDefaultInstance(), + onOverlapSelected = onUpdateSelfOverlap + ) + + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider() + + TextureImportSection( + textureCount = activeProto.textureIdToBitmapMap.size, + texturePickerLauncher = texturePickerLauncher + ) +} + +/** + * Editor for a single paint preference, containing its own texture layers + * and color functions via nested [EditableListWidget]. + */ +@Composable +private fun PaintPreferenceEditor( + paint: ProtoBrushPaint, + availableTextures: List, + onPaintChanged: (ProtoBrushPaint) -> Unit, + getTextureBitmap: (String) -> Bitmap? +) { + EditableListWidget( + title = stringResource(R.string.brush_designer_texture_layers), + items = paint.textureLayersList, + defaultItem = ProtoBrushPaint.TextureLayer.newBuilder() + .setSizeUnit(ProtoBrushPaint.TextureLayer.SizeUnit.SIZE_UNIT_BRUSH_SIZE) + .setSizeX(1f).setSizeY(1f) + .setMapping(ProtoBrushPaint.TextureLayer.Mapping.MAPPING_TILING) + .setBlendMode(ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_OVER) + .build(), + onItemsChanged = { newLayers -> + onPaintChanged( + paint.toBuilder().clearTextureLayers() + .also { b -> newLayers.forEach { b.addTextureLayers(it) } } + .build() + ) + }, + itemHeader = { layer -> + val texId = + layer.clientTextureId.ifEmpty { stringResource(R.string.brush_designer_empty) } + val blend = layer.blendMode.name.replace("BLEND_MODE_", "") + "$texId ($blend)" + }, + editorContent = { layer, onLayerChanged -> + TextureLayerEditor( + layer = layer, + availableTextures = availableTextures, + onLayerChanged = onLayerChanged, + getTextureBitmap = getTextureBitmap + ) + } + ) + + Spacer(modifier = Modifier.height(8.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(8.dp)) + + if (paint.colorFunctionsList.isEmpty()) { + Text( + text = stringResource(R.string.brush_designer_no_color_functions), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 4.dp) + ) + } + EditableListWidget( + title = stringResource(R.string.brush_designer_color_functions), + items = paint.colorFunctionsList, + defaultItem = ProtoColorFunction.newBuilder().setOpacityMultiplier(1f).build(), + onItemsChanged = { newFuncs -> + onPaintChanged( + paint.toBuilder().clearColorFunctions() + .also { b -> newFuncs.forEach { b.addColorFunctions(it) } } + .build() + ) + }, + itemHeader = { func -> + when (func.functionCase) { + ProtoColorFunction.FunctionCase.OPACITY_MULTIPLIER -> + stringResource( + R.string.brush_designer_opacity_multiplier, + func.opacityMultiplier + ) + + ProtoColorFunction.FunctionCase.REPLACE_COLOR -> + stringResource(R.string.brush_designer_replace_color) + + else -> stringResource(R.string.brush_designer_unknown) + } + }, + editorContent = { func, onFuncChanged -> + ColorFunctionEditor( + colorFunction = func, + onFunctionChanged = onFuncChanged + ) + } + ) +} + +/** + * Full editor for a single [ProtoBrushPaint.TextureLayer], including all + * fields from SSA: texture ID, mapping, size unit, scale, rotation, + * origin, offset, wrap, blend mode, and animation. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TextureLayerEditor( + layer: ProtoBrushPaint.TextureLayer, + availableTextures: List, + onLayerChanged: (ProtoBrushPaint.TextureLayer) -> Unit, + getTextureBitmap: (String) -> Bitmap? +) { + val bitmap = if (layer.clientTextureId.isNotEmpty()) { + getTextureBitmap(layer.clientTextureId) + } else null + if (bitmap != null) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = stringResource( + id = R.string.brush_designer_texture_content_desc, + layer.clientTextureId + ), + modifier = Modifier + .size(80.dp) + .padding(bottom = 8.dp) + ) + } + + TextureIdSelector( + currentId = layer.clientTextureId, + availableTextures = availableTextures, + onTextureSelected = { id -> + onLayerChanged(layer.toBuilder().setClientTextureId(id).build()) + } + ) + + EnumDropdown( + label = stringResource(R.string.brush_designer_mapping_mode), + currentValue = layer.mapping, + values = ProtoBrushPaint.TextureLayer.Mapping.entries.filter { + it != ProtoBrushPaint.TextureLayer.Mapping.MAPPING_UNSPECIFIED + }, + displayName = { it.name.replace("MAPPING_", "") }, + onSelected = { onLayerChanged(layer.toBuilder().setMapping(it).build()) } + ) + + if (layer.mapping == ProtoBrushPaint.TextureLayer.Mapping.MAPPING_TILING) { + EnumDropdown( + label = stringResource(R.string.brush_designer_size_unit), + currentValue = layer.sizeUnit, + values = ProtoBrushPaint.TextureLayer.SizeUnit.entries.filter { + it != ProtoBrushPaint.TextureLayer.SizeUnit.SIZE_UNIT_UNSPECIFIED + }, + displayName = { it.name.replace("SIZE_UNIT_", "") }, + onSelected = { onLayerChanged(layer.toBuilder().setSizeUnit(it).build()) } + ) + + NumericField( + title = stringResource(R.string.brush_designer_scale_x), + value = if (layer.hasSizeX()) layer.sizeX else 1f, + limits = NumericLimits.standard(0.1f, 10f, 0.1f), + onValueChanged = { onLayerChanged(layer.toBuilder().setSizeX(it).build()) } + ) + NumericField( + title = stringResource(R.string.brush_designer_scale_y), + value = if (layer.hasSizeY()) layer.sizeY else 1f, + limits = NumericLimits.standard(0.1f, 10f, 0.1f), + onValueChanged = { onLayerChanged(layer.toBuilder().setSizeY(it).build()) } + ) + } + + NumericField( + title = stringResource(R.string.brush_designer_rotation), + value = if (layer.hasRotationInRadians()) layer.rotationInRadians else 0f, + limits = NumericLimits.radiansShownAsDegrees(0f, 360f), + onValueChanged = { + onLayerChanged(layer.toBuilder().setRotationInRadians(it).build()) + } + ) + + EnumDropdown( + label = stringResource(R.string.brush_designer_origin), + currentValue = layer.origin, + values = ProtoBrushPaint.TextureLayer.Origin.entries.toList(), + displayName = { it.name.replace("ORIGIN_", "") }, + onSelected = { onLayerChanged(layer.toBuilder().setOrigin(it).build()) } + ) + + NumericField( + title = stringResource(R.string.brush_designer_offset_x), + value = if (layer.hasOffsetX()) layer.offsetX else 0f, + limits = NumericLimits.standard(-5f, 5f, 0.1f), + onValueChanged = { onLayerChanged(layer.toBuilder().setOffsetX(it).build()) } + ) + NumericField( + title = stringResource(R.string.brush_designer_offset_y), + value = if (layer.hasOffsetY()) layer.offsetY else 0f, + limits = NumericLimits.standard(-5f, 5f, 0.1f), + onValueChanged = { onLayerChanged(layer.toBuilder().setOffsetY(it).build()) } + ) + + EnumDropdown( + label = stringResource(R.string.brush_designer_wrap_x), + currentValue = layer.wrapX, + values = ProtoBrushPaint.TextureLayer.Wrap.entries.toList(), + displayName = { it.name.replace("WRAP_", "") }, + onSelected = { onLayerChanged(layer.toBuilder().setWrapX(it).build()) } + ) + EnumDropdown( + label = stringResource(R.string.brush_designer_wrap_y), + currentValue = layer.wrapY, + values = ProtoBrushPaint.TextureLayer.Wrap.entries.toList(), + displayName = { it.name.replace("WRAP_", "") }, + onSelected = { onLayerChanged(layer.toBuilder().setWrapY(it).build()) } + ) + + EnumDropdown( + label = stringResource(R.string.brush_designer_blend_mode), + currentValue = layer.blendMode, + values = ProtoBrushPaint.TextureLayer.BlendMode.entries.filter { + it != ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_UNSPECIFIED + }, + displayName = { it.name.replace("BLEND_MODE_", "") }, + onSelected = { onLayerChanged(layer.toBuilder().setBlendMode(it).build()) } + ) + Text( + text = blendModeDescription(layer.blendMode), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 4.dp, bottom = 4.dp) + ) + + if (layer.mapping == ProtoBrushPaint.TextureLayer.Mapping.MAPPING_STAMPING) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + stringResource(R.string.brush_designer_animation), + style = MaterialTheme.typography.labelLarge + ) + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f) + ), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(12.dp)) { + NumericField( + title = stringResource(R.string.brush_designer_rows), + value = if (layer.hasAnimationRows()) layer.animationRows.toFloat() else 1f, + limits = NumericLimits.standard(1f, 10f, 1f), + onValueChanged = { + onLayerChanged( + layer.toBuilder().setAnimationRows(it.toInt()).build() + ) + } + ) + NumericField( + title = stringResource(R.string.brush_designer_columns), + value = if (layer.hasAnimationColumns()) layer.animationColumns.toFloat() + else 1f, + limits = NumericLimits.standard(1f, 10f, 1f), + onValueChanged = { + onLayerChanged( + layer.toBuilder().setAnimationColumns(it.toInt()).build() + ) + } + ) + NumericField( + title = stringResource(R.string.brush_designer_frames), + value = if (layer.hasAnimationFrames()) layer.animationFrames.toFloat() + else 1f, + limits = NumericLimits.standard(1f, 64f, 1f), + onValueChanged = { + onLayerChanged( + layer.toBuilder().setAnimationFrames(it.toInt()).build() + ) + } + ) + NumericField( + title = stringResource(R.string.brush_designer_duration_seconds), + value = if (layer.hasAnimationDurationSeconds()) + layer.animationDurationSeconds else 0f, + limits = NumericLimits.standard(0f, 5f, 0.1f), + onValueChanged = { + onLayerChanged( + layer.toBuilder().setAnimationDurationSeconds(it).build() + ) + } + ) + } + } + } +} + +/** + * Editor for a single [ProtoColorFunction], supporting OpacityMultiplier + * and ReplaceColor function types. + */ +@Composable +private fun ColorFunctionEditor( + colorFunction: ProtoColorFunction, + onFunctionChanged: (ProtoColorFunction) -> Unit +) { + val functionTypes = listOf("Opacity Multiplier", "Replace Color") + val currentType = when (colorFunction.functionCase) { + ProtoColorFunction.FunctionCase.REPLACE_COLOR -> 1 + else -> 0 + } + + EnumDropdown( + label = stringResource(R.string.brush_designer_function_type), + currentValue = functionTypes[currentType], + values = functionTypes, + displayName = { it }, + onSelected = { selected -> + when (functionTypes.indexOf(selected)) { + 0 -> onFunctionChanged( + ProtoColorFunction.newBuilder().setOpacityMultiplier(1f).build() + ) + + 1 -> onFunctionChanged( + ProtoColorFunction.newBuilder() + .setReplaceColor( + ink.proto.Color.getDefaultInstance() + ) + .build() + ) + } + } + ) + + when (colorFunction.functionCase) { + ProtoColorFunction.FunctionCase.OPACITY_MULTIPLIER -> { + NumericField( + title = stringResource(R.string.brush_designer_opacity_multiplier_label), + value = colorFunction.opacityMultiplier, + limits = NumericLimits.standard(0f, 2f, 0.05f), + onValueChanged = { + onFunctionChanged( + ProtoColorFunction.newBuilder().setOpacityMultiplier(it).build() + ) + } + ) + } + + ProtoColorFunction.FunctionCase.REPLACE_COLOR -> { + Text( + text = stringResource(R.string.brush_designer_replace_color_message), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(vertical = 4.dp) + ) + } + + else -> { + Text( + text = stringResource(R.string.brush_designer_unknown_color_function), + style = MaterialTheme.typography.bodySmall + ) + } + } +} + +/** + * A generic [ExposedDropdownMenuBox] for selecting from enum-like value lists. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun EnumDropdown( + label: String, + currentValue: T, + values: List, + displayName: (T) -> String, + onSelected: (T) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it } + ) { + OutlinedTextField( + value = displayName(currentValue), + onValueChange = {}, + readOnly = true, + label = { Text(label) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + }, + modifier = Modifier + .menuAnchor() + .fillMaxWidth() + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + values.forEach { value -> + DropdownMenuItem( + text = { Text(displayName(value)) }, + onClick = { + onSelected(value) + expanded = false + } + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TextureIdSelector( + currentId: String, + availableTextures: List, + onTextureSelected: (String) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it } + ) { + OutlinedTextField( + value = currentId.ifEmpty { stringResource(R.string.brush_designer_no_texture) }, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.brush_designer_texture_id)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + }, + modifier = Modifier + .menuAnchor() + .fillMaxWidth() + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + availableTextures.forEach { id -> + DropdownMenuItem( + text = { Text(id) }, + onClick = { + onTextureSelected(id) + expanded = false + } + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SelfOverlapSelector( + currentPaint: ProtoBrushPaint, + onOverlapSelected: (ProtoBrushPaint.SelfOverlap) -> Unit +) { + var overlapExpanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = overlapExpanded, + onExpandedChange = { overlapExpanded = it } + ) { + OutlinedTextField( + value = currentPaint.selfOverlap.name.replace("_", " "), + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.brush_designer_self_overlap)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = overlapExpanded) + }, + modifier = Modifier + .menuAnchor() + .fillMaxWidth() + ) + ExposedDropdownMenu( + expanded = overlapExpanded, + onDismissRequest = { overlapExpanded = false } + ) { + ProtoBrushPaint.SelfOverlap.entries + .filter { it != ProtoBrushPaint.SelfOverlap.SELF_OVERLAP_UNSPECIFIED } + .forEach { overlap -> + DropdownMenuItem( + text = { Text(overlap.name) }, + onClick = { + onOverlapSelected(overlap) + overlapExpanded = false + } + ) + } + } + } +} + +@Composable +private fun TextureImportSection( + textureCount: Int, + texturePickerLauncher: androidx.activity.result.ActivityResultLauncher< + androidx.activity.result.PickVisualMediaRequest> +) { + Text( + text = stringResource(R.string.brush_designer_textures), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 16.dp) + ) + Button( + onClick = { + texturePickerLauncher.launch( + androidx.activity.result.PickVisualMediaRequest( + ActivityResultContracts.PickVisualMedia.ImageOnly + ) + ) + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.brush_designer_import_texture)) + } + + Text( + stringResource(R.string.brush_designer_loaded_textures, textureCount), + style = MaterialTheme.typography.bodySmall + ) +} + +/** Returns a short human-readable description for each blend mode. */ +private fun blendModeDescription(mode: ProtoBrushPaint.TextureLayer.BlendMode): String = + when (mode) { + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_OVER -> + "Default — texture drawn over destination." + + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_DST_IN -> + "Keeps destination where texture is opaque." + + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_DST_OUT -> + "Cuts out destination where texture is opaque." + + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_ATOP -> + "Draws texture only where destination exists." + + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_IN -> + "Draws texture only where destination is opaque." + + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_MODULATE -> + "Multiplies source and destination colors." + + else -> "" + } diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/PreviewPane.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/PreviewPane.kt new file mode 100644 index 0000000..9713322 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/PreviewPane.kt @@ -0,0 +1,318 @@ +/* + * 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.brushdesigner.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +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.mutableStateListOf +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.ink.brush.Brush +import androidx.ink.brush.ExperimentalInkCustomBrushApi +import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer +import androidx.ink.strokes.Stroke +import com.example.cahier.R +import com.example.cahier.core.ui.CahierTextureBitmapStore +import com.example.cahier.core.ui.DrawingSurface +import com.example.cahier.core.ui.theme.BrushBlack +import com.example.cahier.core.ui.theme.BrushBlue +import com.example.cahier.core.ui.theme.BrushGreen +import com.example.cahier.core.ui.theme.BrushRed +import com.example.cahier.core.ui.theme.BrushYellow +import ink.proto.BrushFamily as ProtoBrushFamily + +/** + * The drawing preview pane where users can test/preview their custom brush. + * Includes the canvas, size selector, and color picker controls. + * + * Stateless: receives all state and callbacks, does not access ViewModel. + */ +@OptIn(ExperimentalInkCustomBrushApi::class) +@Composable +internal fun PreviewPane( + modifier: Modifier = Modifier, + activeBrush: Brush?, + activeProto: ProtoBrushFamily, + strokes: List, + brushColor: Color, + brushSize: Float, + onSetTextureStore: (CahierTextureBitmapStore) -> Unit, + onReplaceStrokes: (List) -> Unit, + onStrokesFinished: (List) -> Unit, + onGetNextBrush: () -> Brush, + onSetBrushColor: (Color) -> Unit, + onSetBrushSize: (Float) -> Unit +) { + val context = LocalContext.current + val textureStore = remember { CahierTextureBitmapStore(context) } + + LaunchedEffect(textureStore) { + onSetTextureStore(textureStore) + } + + val canvasStrokeRenderer = remember(textureStore) { + CanvasStrokeRenderer.create(textureStore = textureStore) + } + val localStrokes = remember { mutableStateListOf() } + var showCustomColorPicker by remember { mutableStateOf(false) } + + LaunchedEffect(strokes) { + if (localStrokes != strokes) { + localStrokes.clear() + localStrokes.addAll(strokes) + } + } + + LaunchedEffect(activeBrush) { + if (activeBrush != null && localStrokes.isNotEmpty()) { + val updatedStrokes = localStrokes.map { it.copy(brush = activeBrush) } + localStrokes.clear() + localStrokes.addAll(updatedStrokes) + onReplaceStrokes(updatedStrokes) + } + } + + Box( + modifier = modifier + .background( + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(12.dp) + ) + .border( + width = 2.dp, MaterialTheme.colorScheme.outlineVariant, + shape = RoundedCornerShape(12.dp) + ) + .clip(RoundedCornerShape(12.dp)), + contentAlignment = Alignment.Center + ) { + if (showCustomColorPicker) { + CustomColorPickerDialog( + initialColor = brushColor, + onColorSelected = onSetBrushColor, + onDismissRequest = { showCustomColorPicker = false } + ) + } + + if (activeBrush != null) { + DrawingSurface( + strokes = localStrokes, + canvasStrokeRenderer = canvasStrokeRenderer, + textureStore = textureStore, + onStrokesFinished = { newStrokes -> + localStrokes.addAll(newStrokes) + onStrokesFinished(newStrokes) + }, + onErase = { _, _ -> }, + onEraseStart = { }, + onEraseEnd = { }, + currentBrush = activeBrush, + onGetNextBrush = onGetNextBrush, + isEraserMode = false, + backgroundImageUri = null, + onStartDrag = {}, + modifier = Modifier.fillMaxSize() + ) + + PreviewToolbar( + brushSize = brushSize, + brushColor = brushColor, + onSizeSelected = onSetBrushSize, + onColorSelected = onSetBrushColor, + onShowCustomColorPicker = { showCustomColorPicker = true }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(16.dp) + ) + } else { + if (activeProto.coatsCount == 0) { + Text( + text = stringResource(R.string.brush_designer_invalid_brush), + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge + ) + } else { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.outline + ) + } + } + } +} + +/** + * Toolbar overlay with brush size and color selectors. + */ +@Composable +private fun PreviewToolbar( + brushSize: Float, + brushColor: Color, + onSizeSelected: (Float) -> Unit, + onColorSelected: (Color) -> Unit, + onShowCustomColorPicker: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .background( + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.9f), + shape = RoundedCornerShape(50) + ) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + SizeSelector( + brushSize = brushSize, + onSizeSelected = onSizeSelected + ) + + VerticalDivider(modifier = Modifier.height(24.dp)) + + ColorSelector( + brushColor = brushColor, + onColorSelected = onColorSelected, + onShowCustomColorPicker = onShowCustomColorPicker + ) + } +} + +@Composable +private fun SizeSelector( + brushSize: Float, + onSizeSelected: (Float) -> Unit +) { + var sizeMenuExpanded by remember { mutableStateOf(false) } + Box { + TextButton(onClick = { sizeMenuExpanded = true }) { + Text( + stringResource( + id = R.string.brush_designer_size_px, brushSize.toInt() + ), + fontWeight = FontWeight.Bold + ) + } + DropdownMenu( + expanded = sizeMenuExpanded, + onDismissRequest = { sizeMenuExpanded = false } + ) { + listOf(2f, 5f, 10f, 15f, 25f, 50f, 100f).forEach { size -> + DropdownMenuItem( + text = { Text(stringResource(R.string.brush_designer_size_px, size.toInt())) }, + onClick = { + onSizeSelected(size) + sizeMenuExpanded = false + } + ) + } + } + } +} + +@Composable +private fun ColorSelector( + brushColor: Color, + onColorSelected: (Color) -> Unit, + onShowCustomColorPicker: () -> Unit +) { + var colorMenuExpanded by remember { mutableStateOf(false) } + Box { + IconButton(onClick = { colorMenuExpanded = true }) { + Icon( + painterResource(R.drawable.circle_24px), + contentDescription = stringResource(R.string.color), + tint = brushColor + ) + } + DropdownMenu( + expanded = colorMenuExpanded, + onDismissRequest = { colorMenuExpanded = false } + ) { + val colors = mapOf( + stringResource(R.string.brush_designer_color_black) to BrushBlack, + stringResource(R.string.brush_designer_color_red) to BrushRed, + stringResource(R.string.brush_designer_color_blue) to BrushBlue, + stringResource(R.string.brush_designer_color_green) to BrushGreen, + stringResource(R.string.brush_designer_color_yellow) to BrushYellow + ) + colors.forEach { (name, color) -> + DropdownMenuItem( + text = { Text(name) }, + leadingIcon = { + Icon( + painterResource(R.drawable.circle_24px), + contentDescription = name, + tint = color + ) + }, + onClick = { + onColorSelected(color) + colorMenuExpanded = false + } + ) + } + HorizontalDivider(modifier = Modifier.padding(horizontal = 8.dp)) + + DropdownMenuItem( + text = { Text(stringResource(R.string.brush_designer_custom_color)) }, + leadingIcon = { + Icon( + painterResource(R.drawable.circle_24px), + contentDescription = stringResource(R.string.brush_designer_custom_color), + tint = brushColor + ) + }, + onClick = { + colorMenuExpanded = false + onShowCustomColorPicker() + } + ) + } + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/TipPreview.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/TipPreview.kt new file mode 100644 index 0000000..59002a5 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/TipPreview.kt @@ -0,0 +1,135 @@ +/* + * 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.brushdesigner.ui + +import android.annotation.SuppressLint +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.unit.dp +import androidx.core.graphics.withSave +import androidx.ink.brush.Brush +import androidx.ink.brush.ExperimentalInkCustomBrushApi +import androidx.ink.brush.InputToolType +import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer +import androidx.ink.strokes.MutableStrokeInputBatch +import androidx.ink.strokes.Stroke +import com.example.cahier.core.ui.CahierTextureBitmapStore + +/** + * Renders a live stroke preview of the current brush — a Z-shaped squiggle + * that shows how the brush actually paints (tip shape, texture, behaviors). + * + * Re-renders whenever the [brush] or [textureStore] changes. + */ +@SuppressLint("RestrictedApi") +@OptIn(ExperimentalInkCustomBrushApi::class) +@Composable +internal fun TipPreview( + brush: Brush?, + textureStore: CahierTextureBitmapStore?, + modifier: Modifier = Modifier +) { + val strokeRenderer = remember(textureStore) { + if (textureStore != null) { + CanvasStrokeRenderer.create(textureStore = textureStore) + } else { + CanvasStrokeRenderer.create() + } + } + + val gridColor = MaterialTheme.colorScheme.outlineVariant + + Card( + modifier = modifier + .fillMaxWidth() + .height(150.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer + ) + ) { + Canvas( + modifier = Modifier + .fillMaxSize() + .padding(8.dp) + ) { + if (brush == null) return@Canvas + + val w = size.width + val h = size.height + + drawLine( + color = gridColor, + start = Offset(w / 2, 0f), + end = Offset(w / 2, h), + strokeWidth = 0.5f + ) + drawLine( + color = gridColor, + start = Offset(0f, h / 2), + end = Offset(w, h / 2), + strokeWidth = 0.5f + ) + + val inputBatch = MutableStrokeInputBatch() + val points = listOf( + Triple(w * 0.1f, h * 0.3f, 0L), + Triple(w * 0.25f, h * 0.25f, 10L), + Triple(w * 0.45f, h * 0.2f, 20L), + Triple(w * 0.55f, h * 0.35f, 30L), + Triple(w * 0.5f, h * 0.5f, 40L), + Triple(w * 0.45f, h * 0.65f, 50L), + Triple(w * 0.55f, h * 0.8f, 60L), + Triple(w * 0.75f, h * 0.75f, 70L), + Triple(w * 0.9f, h * 0.7f, 80L), + ) + + points.forEachIndexed { index, (x, y, timeMs) -> + inputBatch.add( + type = InputToolType.STYLUS, + x = x, + y = y, + elapsedTimeMillis = timeMs, + pressure = 0.5f + (index.toFloat() / points.size) * 0.3f, + tiltRadians = 0.3f, + orientationRadians = 0f + ) + } + + val stroke = Stroke(brush = brush, inputs = inputBatch) + + val nativeCanvas = drawContext.canvas.nativeCanvas + nativeCanvas.withSave { + strokeRenderer.draw( + stroke = stroke, + canvas = this, + strokeToScreenTransform = android.graphics.Matrix() + ) + } + } + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/TipShapeTab.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/TipShapeTab.kt new file mode 100644 index 0000000..fd87f4f --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/TipShapeTab.kt @@ -0,0 +1,169 @@ +/* + * 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.brushdesigner.ui + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +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.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.ink.brush.Brush +import androidx.ink.brush.ExperimentalInkCustomBrushApi +import com.example.cahier.R +import com.example.cahier.core.ui.CahierTextureBitmapStore +import ink.proto.BrushTip as ProtoBrushTip + +/** + * Tab 0: Tip geometry controls — scale (with lock toggle), corner rounding, + * slant, rotation, pinch, and particle (stamp) settings. + * + * Uses [NumericField] for professional ±button input with degree/percent + * unit conversions and long-press exact-value entry. + * + * Stateless: receives data and callbacks, does not access ViewModel. + */ +@OptIn(ExperimentalInkCustomBrushApi::class) +@Composable +internal fun TipShapeTabContent( + currentTip: ProtoBrushTip, + activeBrush: Brush?, + textureStore: CahierTextureBitmapStore?, + onUpdateTip: (ProtoBrushTip.Builder.() -> Unit) -> Unit +) { + var isScaleLocked by remember { mutableStateOf(false) } + + TipPreview( + brush = activeBrush, + textureStore = textureStore, + modifier = Modifier.padding(vertical = 8.dp) + ) + + Text( + stringResource(R.string.brush_designer_tip_geometry), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 8.dp) + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.brush_designer_lock_scale_ratio), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) + ) + Switch(checked = isScaleLocked, onCheckedChange = { isScaleLocked = it }) + } + + NumericField( + title = stringResource(R.string.brush_designer_tip_scale_x), + value = if (currentTip.hasScaleX()) currentTip.scaleX else 1f, + limits = NumericLimits.floatShownAsPercent(10f, 200f), + onValueChanged = { newX -> + onUpdateTip { + val oldX = if (hasScaleX()) scaleX else 1f + val oldY = if (hasScaleY()) scaleY else 1f + setScaleX(newX) + if (isScaleLocked && oldX > 0f) { + setScaleY(oldY * (newX / oldX)) + } + } + } + ) + + NumericField( + title = stringResource(R.string.brush_designer_tip_scale_y), + value = if (currentTip.hasScaleY()) currentTip.scaleY else 1f, + limits = NumericLimits.floatShownAsPercent(10f, 200f), + onValueChanged = { newY -> + onUpdateTip { + val oldX = if (hasScaleX()) scaleX else 1f + val oldY = if (hasScaleY()) scaleY else 1f + setScaleY(newY) + if (isScaleLocked && oldY > 0f) { + setScaleX(oldX * (newY / oldY)) + } + } + } + ) + + NumericField( + title = stringResource(R.string.brush_designer_corner_rounding), + value = if (currentTip.hasCornerRounding()) currentTip.cornerRounding else 1f, + limits = NumericLimits.floatShownAsPercent(0f, 100f), + onValueChanged = { newValue -> onUpdateTip { setCornerRounding(newValue) } } + ) + + NumericField( + title = stringResource(R.string.brush_designer_slant), + value = if (currentTip.hasSlantRadians()) currentTip.slantRadians else 0f, + limits = NumericLimits.radiansShownAsDegrees(-90f, 90f), + onValueChanged = { newValue -> onUpdateTip { setSlantRadians(newValue) } } + ) + + NumericField( + title = stringResource(R.string.brush_designer_tip_rotation), + value = if (currentTip.hasRotationRadians()) currentTip.rotationRadians else 0f, + limits = NumericLimits.radiansShownAsDegrees(0f, 360f), + onValueChanged = { newValue -> onUpdateTip { setRotationRadians(newValue) } } + ) + + NumericField( + title = stringResource(R.string.brush_designer_pinch), + value = if (currentTip.hasPinch()) currentTip.pinch else 0f, + limits = NumericLimits.floatShownAsPercent(0f, 100f), + onValueChanged = { newValue -> onUpdateTip { setPinch(newValue) } } + ) + + HorizontalDivider() + Text( + stringResource(R.string.brush_designer_particle_settings), + style = MaterialTheme.typography.titleSmall + ) + + NumericField( + title = stringResource(R.string.brush_designer_gap_distance_scale), + value = if (currentTip.hasParticleGapDistanceScale()) currentTip + .particleGapDistanceScale else 0f, + limits = NumericLimits.standard(0f, 5f, 0.1f), + onValueChanged = { newValue -> + onUpdateTip { setParticleGapDistanceScale(newValue) } + } + ) + + NumericField( + title = stringResource(R.string.brush_designer_gap_duration_ms), + value = if (currentTip.hasParticleGapDurationSeconds()) currentTip + .particleGapDurationSeconds * 1000f else 0f, + limits = NumericLimits.standard(0f, 250f, 5f), + onValueChanged = { newValue -> + onUpdateTip { setParticleGapDurationSeconds(newValue / 1000f) } + } + ) +} diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/viewmodel/BrushDesignerViewModel.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/viewmodel/BrushDesignerViewModel.kt new file mode 100644 index 0000000..3b5bdba --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/viewmodel/BrushDesignerViewModel.kt @@ -0,0 +1,631 @@ +/* + * + * * Copyright 2025 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.brushdesigner.viewmodel + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import androidx.compose.ui.graphics.Color +import androidx.ink.brush.Brush +import androidx.ink.brush.BrushFamily +import androidx.ink.brush.ExperimentalInkCustomBrushApi +import androidx.ink.brush.compose.createWithComposeColor +import androidx.ink.storage.decode +import androidx.ink.storage.encode +import androidx.ink.strokes.Stroke +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.cahier.core.ui.CahierTextureBitmapStore +import com.example.cahier.core.ui.theme.BrushBlack +import com.example.cahier.developer.brushdesigner.data.BrushDesignerRepository +import com.example.cahier.developer.brushdesigner.data.CustomBrushDao +import com.example.cahier.developer.brushdesigner.data.CustomBrushEntity +import com.google.protobuf.ByteString +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import ink.proto.BrushBehavior +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream +import javax.inject.Inject +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 + +@OptIn(ExperimentalInkCustomBrushApi::class, FlowPreview::class) +@HiltViewModel +class BrushDesignerViewModel @Inject constructor( + @param:ApplicationContext private val context: Context, + private val repository: BrushDesignerRepository, + private val customBrushDao: CustomBrushDao +) : ViewModel() { + + val activeBrushProto: StateFlow = repository.activeBrushProto + + val testStrokes: StateFlow> = repository.testStrokes + + private val _brushColor = MutableStateFlow(BrushBlack) + val brushColor: StateFlow = _brushColor.asStateFlow() + + val previewBrushFamily: StateFlow = repository.activeBrushProto + .debounce(150) + .map { proto -> + withContext(Dispatchers.IO) { + try { + val rawBytes = proto.toByteArray() + val baos = ByteArrayOutputStream() + GZIPOutputStream(baos).use { it.write(rawBytes) } + + ByteArrayInputStream(baos.toByteArray()).use { inputStream -> + BrushFamily.decode(inputStream) + } + } catch (e: Exception) { + null + } + } + } + .flowOn(Dispatchers.Default) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = null + ) + + private val _brushSize = MutableStateFlow(15f) + val brushSize: StateFlow = _brushSize.asStateFlow() + + val activeBrush: StateFlow = combine( + previewBrushFamily, + _brushColor, + _brushSize + ) { family, color, size -> + if (family == null) null + else Brush.createWithComposeColor( + family = family, + color = color, + size = size, + epsilon = 0.1f + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = null + ) + + private val autoSaveFile = File(context.cacheDir, "autosave.brush") + + private var textureStore: CahierTextureBitmapStore? = null + + init { + if (autoSaveFile.exists()) { + loadBrushFromFile(Uri.fromFile(autoSaveFile)) + } + + viewModelScope.launch(Dispatchers.IO) { + repository.activeBrushProto + .debounce(1000L) + .collect { proto -> + try { + autoSaveFile.outputStream().use { outputStream -> + GZIPOutputStream(outputStream).use { gzip -> + gzip.write(proto.toByteArray()) + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + viewModelScope.launch(Dispatchers.IO) { + repository.activeBrushProto + .map { it.textureIdToBitmapMap } + .distinctUntilChanged() + .collect { map -> + syncProtobufTexturesToStore(map) + } + } + } + + val savedPaletteBrushes: StateFlow> = + customBrushDao.getAllCustomBrushes() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = emptyList() + ) + + private val _selectedCoatIndex = MutableStateFlow(0) + val selectedCoatIndex: StateFlow = _selectedCoatIndex.asStateFlow() + + private fun syncProtobufTexturesToStore(map: Map) { + map.forEach { (id, byteString) -> + if (textureStore?.get(id) == null) { + try { + val bytes = byteString.toByteArray() + val bitmap = BitmapFactory + .decodeByteArray(bytes, 0, bytes.size) + if (bitmap != null) { + textureStore?.loadTexture(id, bitmap) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + } + + fun getActiveBrush(): Brush? = activeBrush.value + + fun onStrokesFinished(newStrokes: List) { + repository.updateTestStrokes(repository.testStrokes.value + newStrokes) + } + + fun replaceStrokes(updatedStrokes: List) { + repository.updateTestStrokes(updatedStrokes) + } + + fun clearCanvas() { + repository.updateTestStrokes(emptyList()) + } + + + fun saveBrushToFile(uri: Uri) { + viewModelScope.launch(Dispatchers.IO) { + try { + context.contentResolver.openOutputStream(uri)?.use { outputStream -> + GZIPOutputStream(outputStream).use { gzipStream -> + gzipStream.write(repository.activeBrushProto.value.toByteArray()) + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + fun loadBrushFromFile(uri: Uri) { + viewModelScope.launch { + try { + val bytes = withContext(Dispatchers.IO) { + context.contentResolver.openInputStream(uri)?.use { inputStream -> + GZIPInputStream(inputStream).use { gzipStream -> + gzipStream.readBytes() + } + } + } + if (bytes != null) { + val loadedProto = ProtoBrushFamily.parseFrom(bytes) + repository.updateActiveBrushProto(loadedProto) + clearCanvas() + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + fun loadStockBrush(stockBrush: BrushFamily) { + viewModelScope.launch(Dispatchers.IO) { + try { + val baos = ByteArrayOutputStream() + stockBrush.encode(baos) + + val gzippedBytes = baos.toByteArray() + ByteArrayInputStream(gzippedBytes).use { inputStream -> + GZIPInputStream(inputStream).use { gzipStream -> + val rawBytes = gzipStream.readBytes() + val loadedProto = ProtoBrushFamily.parseFrom(rawBytes) + repository.updateActiveBrushProto(loadedProto) + clearCanvas() + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + fun updateClientBrushFamilyId(id: String) { + val builder = repository.activeBrushProto.value.toBuilder() + builder.clientBrushFamilyId = id + repository.updateActiveBrushProto(builder.build()) + } + + fun updateDeveloperComment(comment: String) { + val builder = repository.activeBrushProto.value.toBuilder() + builder.developerComment = comment + repository.updateActiveBrushProto(builder.build()) + } + + fun updateTip(updateBlock: (ProtoBrushTip.Builder) -> Unit) { + val familyBuilder = repository.activeBrushProto.value.toBuilder() + val index = _selectedCoatIndex.value + + val coatBuilder = familyBuilder.getCoats(index).toBuilder() + val tipBuilder = coatBuilder.tip.toBuilder() + updateBlock(tipBuilder) + + coatBuilder.setTip(tipBuilder) + familyBuilder.setCoats(index, coatBuilder) + repository.updateActiveBrushProto(familyBuilder.build()) + } + + fun updateSelfOverlap(overlap: ProtoBrushPaint.SelfOverlap) { + val familyBuilder = repository.activeBrushProto.value.toBuilder() + val index = _selectedCoatIndex.value + + if (familyBuilder.coatsCount <= index) { + familyBuilder.addCoats( + ProtoBrushCoat.newBuilder() + .setTip(ProtoBrushTip.newBuilder()) + ) + } + val coatBuilder = familyBuilder.getCoats(index).toBuilder() + + if (coatBuilder.paintPreferencesCount == 0) { + coatBuilder.addPaintPreferences(ProtoBrushPaint.newBuilder()) + } + val paintBuilder = coatBuilder.getPaintPreferences(0).toBuilder() + + paintBuilder.selfOverlap = overlap + + coatBuilder.setPaintPreferences(0, paintBuilder) + familyBuilder.setCoats(index, coatBuilder) + repository.updateActiveBrushProto(familyBuilder.build()) + } + + fun updateSlidingWindowModel(windowMillis: Long, upsamplingHz: Int) { + val familyBuilder = repository.activeBrushProto.value.toBuilder() + + val inputModelBuilder = familyBuilder.inputModel.toBuilder() + + val swBuilder = inputModelBuilder.slidingWindowModel.toBuilder() + + swBuilder.setWindowSizeSeconds(windowMillis / 1000f) + + if (upsamplingHz <= 0) { + swBuilder.setExperimentalUpsamplingPeriodSeconds(Float.POSITIVE_INFINITY) + } else { + swBuilder.setExperimentalUpsamplingPeriodSeconds(1f / upsamplingHz.toFloat()) + } + + inputModelBuilder.setSlidingWindowModel(swBuilder) + familyBuilder.setInputModel(inputModelBuilder) + + repository.updateActiveBrushProto(familyBuilder.build()) + } + + fun updateInputModelToSpring() { + val familyBuilder = repository.activeBrushProto.value.toBuilder() + val inputModelBuilder = familyBuilder.inputModel.toBuilder() + + inputModelBuilder.setSpringModel(ink.proto.BrushFamily.SpringModel.getDefaultInstance()) + + familyBuilder.setInputModel(inputModelBuilder) + repository.updateActiveBrushProto(familyBuilder.build()) + } + + fun updateInputModelToNaive() { + val familyBuilder = repository.activeBrushProto.value.toBuilder() + val inputModelBuilder = familyBuilder.inputModel.toBuilder() + + inputModelBuilder.setExperimentalNaiveModel( + ink.proto.BrushFamily.ExperimentalNaiveModel.getDefaultInstance() + ) + + familyBuilder.setInputModel(inputModelBuilder) + repository.updateActiveBrushProto(familyBuilder.build()) + } + + fun setBrushColor(color: Color) { + _brushColor.value = color + } + + fun setBrushSize(size: Float) { + _brushSize.value = size + } + + fun saveToPalette(brushName: String): Job { + return viewModelScope.launch(Dispatchers.IO) { + try { + val rawBytes = repository.activeBrushProto.value.toByteArray() + val baos = ByteArrayOutputStream() + + GZIPOutputStream(baos).use { gzip -> + gzip.write(rawBytes) + } + + val finalCompressedBytes = baos.toByteArray() + + customBrushDao.saveCustomBrush( + CustomBrushEntity( + name = brushName, + brushBytes = finalCompressedBytes + ) + ) + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + fun loadFromPalette(entity: CustomBrushEntity) { + viewModelScope.launch(Dispatchers.IO) { + try { + ByteArrayInputStream(entity.brushBytes).use { bais -> + GZIPInputStream(bais).use { gzip -> + val rawBytes = gzip.readBytes() + val loadedProto = ProtoBrushFamily.parseFrom(rawBytes) + + withContext(Dispatchers.Main) { + repository.updateActiveBrushProto(loadedProto) + clearCanvas() + } + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + fun addCustomTexture(uri: Uri, textureId: String) { + viewModelScope.launch(Dispatchers.IO) { + try { + val bitmap = context.contentResolver.openInputStream(uri)?.use { + BitmapFactory.decodeStream(it) + } ?: return@launch + + textureStore?.loadTexture(textureId, bitmap) + + val builder = repository.activeBrushProto.value.toBuilder() + + val baos = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.PNG, 100, baos) + builder.putTextureIdToBitmap( + textureId, + ByteString.copyFrom(baos.toByteArray()) + ) + + val index = _selectedCoatIndex.value + if (builder.coatsCount <= index) { + builder.addCoats( + ProtoBrushCoat + .newBuilder().setTip(ProtoBrushTip.newBuilder()) + ) + } + val coatBuilder = builder.getCoats(index).toBuilder() + + if (coatBuilder.paintPreferencesCount == 0) { + coatBuilder.addPaintPreferences(ProtoBrushPaint.newBuilder()) + } + val paintBuilder = coatBuilder.getPaintPreferences(0).toBuilder() + + val textureLayer = ProtoBrushPaint.TextureLayer.newBuilder() + .setClientTextureId(textureId) + .setMapping(ProtoBrushPaint.TextureLayer.Mapping.MAPPING_TILING) + .setBlendMode(ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_OVER) + .setSizeUnit(ProtoBrushPaint.TextureLayer.SizeUnit.SIZE_UNIT_BRUSH_SIZE) + .setSizeX(1.0f) + .setSizeY(1.0f) + .build() + + paintBuilder.clearTextureLayers() + paintBuilder.addTextureLayers(textureLayer) + + coatBuilder.setPaintPreferences(0, paintBuilder) + builder.setCoats(index, coatBuilder) + + withContext(Dispatchers.Main) { + repository.updateActiveBrushProto(builder.build()) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + fun addBehavior(nodes: List) { + val familyBuilder = repository.activeBrushProto.value.toBuilder() + val index = selectedCoatIndex.value + + if (familyBuilder.coatsCount <= index) return + + val coatBuilder = familyBuilder.getCoats(index).toBuilder() + val tipBuilder = coatBuilder.tip.toBuilder() + + val behavior = BrushBehavior.newBuilder() + .addAllNodes(nodes) + .build() + + tipBuilder.addBehaviors(behavior) + + coatBuilder.setTip(tipBuilder) + familyBuilder.setCoats(index, coatBuilder) + + repository.updateActiveBrushProto(familyBuilder.build()) + } + + fun clearBehaviors() { + val familyBuilder = repository.activeBrushProto.value.toBuilder() + val index = _selectedCoatIndex.value + val coatBuilder = familyBuilder.getCoats(index).toBuilder() + val tipBuilder = coatBuilder.tip.toBuilder() + tipBuilder.clearBehaviors() + coatBuilder.setTip(tipBuilder) + familyBuilder.setCoats(index, coatBuilder) + repository.updateActiveBrushProto(familyBuilder.build()) + } + + /** + * Replaces all paint preferences for the current coat. + * Used by [EditableListWidget] for multi-paint editing. + */ + fun updatePaintPreferences(paints: List) { + val familyBuilder = repository.activeBrushProto.value.toBuilder() + val index = _selectedCoatIndex.value + if (familyBuilder.coatsCount <= index) return + + val coatBuilder = familyBuilder.getCoats(index).toBuilder() + coatBuilder.clearPaintPreferences() + paints.forEach { coatBuilder.addPaintPreferences(it) } + familyBuilder.setCoats(index, coatBuilder) + repository.updateActiveBrushProto(familyBuilder.build()) + } + + /** + * Replaces all behaviors for the current coat's tip. + * Used by [EditableListWidget] for behavior graph editing. + */ + fun updateBehaviorsList(behaviors: List) { + updateTip { tipBuilder -> + tipBuilder.clearBehaviors() + behaviors.forEach { tipBuilder.addBehaviors(it) } + } + } + + /** Returns the loaded bitmap for a texture ID, or null if not loaded. */ + fun getTextureBitmap(textureId: String): Bitmap? = textureStore?.get(textureId) + + /** Returns the current texture store, or null if not yet set. */ + internal fun getTextureStore(): CahierTextureBitmapStore? = textureStore + + fun setTextureStore(store: CahierTextureBitmapStore) { + this.textureStore = store + syncProtobufTexturesToStore(repository.activeBrushProto.value.textureIdToBitmapMap) + } + + fun setSelectedCoat(index: Int) { + _selectedCoatIndex.value = index + } + + fun addNewCoat() { + val familyBuilder = repository.activeBrushProto.value.toBuilder() + + val newCoat = ProtoBrushCoat.newBuilder() + .setTip(ProtoBrushTip.newBuilder().setScaleX(1f).setScaleY(1f).setCornerRounding(1f)) + .addPaintPreferences(ProtoBrushPaint.newBuilder().build()) + .build() + + familyBuilder.addCoats(newCoat) + repository.updateActiveBrushProto(familyBuilder.build()) + + _selectedCoatIndex.value = familyBuilder.coatsCount - 1 + } + + fun deleteSelectedCoat() { + val familyBuilder = repository.activeBrushProto.value.toBuilder() + if (familyBuilder.coatsCount <= 1) return + + val indexToRemove = _selectedCoatIndex.value + familyBuilder.removeCoats(indexToRemove) + + repository.updateActiveBrushProto(familyBuilder.build()) + + _selectedCoatIndex.value = (indexToRemove - 1).coerceAtLeast(0) + } + + /** + * Adds a "Smooth Dynamics" behavior: + * Logic: Source (Input) -> Damping (Smoothing) -> Target (Output) + */ + fun addSmoothedBehavior( + sourceType: BrushBehavior.Source, + targetType: BrushBehavior.Target, + dampingSeconds: Float = 0.1f + ) { + val source = BrushBehavior.Node.newBuilder().setSourceNode( + BrushBehavior.SourceNode.newBuilder() + .setSource(sourceType) + .setSourceValueRangeStart(0f) + .setSourceValueRangeEnd(1f) + .setSourceOutOfRangeBehavior(BrushBehavior.OutOfRange.OUT_OF_RANGE_CLAMP) + ).build() + + val damping = BrushBehavior.Node.newBuilder().setDampingNode( + BrushBehavior.DampingNode.newBuilder() + .setDampingSource(BrushBehavior.ProgressDomain.PROGRESS_DOMAIN_TIME_IN_SECONDS) + .setDampingGap(dampingSeconds) + ).build() + + val target = BrushBehavior.Node.newBuilder().setTargetNode( + BrushBehavior.TargetNode.newBuilder() + .setTarget(targetType) + .setTargetModifierRangeStart(0.5f) + .setTargetModifierRangeEnd(1.5f) + ).build() + + addBehavior(listOf(source, damping, target)) + } + + /** + * Adds a "Random Jitter" behavior: + * Logic: Noise (Random) -> Target (Output) + */ + fun addJitterBehavior(targetType: BrushBehavior.Target) { + val noise = BrushBehavior.Node.newBuilder().setNoiseNode( + BrushBehavior.NoiseNode.newBuilder() + .setSeed(kotlin.random.Random.nextInt()) + .setVaryOver( + BrushBehavior.ProgressDomain.PROGRESS_DOMAIN_DISTANCE_IN_CENTIMETERS + ) + .setBasePeriod(0.5f) + ).build() + + val target = BrushBehavior.Node.newBuilder().setTargetNode( + BrushBehavior.TargetNode.newBuilder() + .setTarget(targetType) + .setTargetModifierRangeStart(0.8f) + .setTargetModifierRangeEnd(1.2f) + ).build() + + addBehavior(listOf(noise, target)) + } + + fun deleteFromPalette(name: String) { + viewModelScope.launch(Dispatchers.IO) { + customBrushDao.deleteCustomBrush(name) + } + } + + companion object { + private const val TAG = "BrushDesignerViewModel" + } +} 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 696b619..5c5d090 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 @@ -296,7 +296,8 @@ class DrawingCanvasViewModel @Inject constructor( if (historyIndex >= 0 && historyIndex < history.size) { val strokesToSave = history[historyIndex] val currentBrushFamily = strokesToSave.lastOrNull()?.brush?.family - val clientBrushFamilyId = _customBrushes.value.find { it.brushFamily == currentBrushFamily }?.name + val clientBrushFamilyId = _customBrushes.value + .find { it.brushFamily == currentBrushFamily }?.name noteRepository.updateNoteStrokes(noteId, strokesToSave, clientBrushFamilyId) } else if (history.isEmpty()) { noteRepository.updateNoteStrokes(noteId, emptyList(), null) @@ -460,22 +461,34 @@ class DrawingCanvasViewModel @Inject constructor( viewModelScope.launch(Dispatchers.IO) { val builtInBrushes = CustomBrushes.getBrushes(context) + val decodedCache = mutableMapOf() + customBrushDao.getAllCustomBrushes().collect { dbBrushes -> + val currentNames = dbBrushes.map { it.name }.toSet() + decodedCache.keys.retainAll(currentNames) + val userBrushes = dbBrushes.mapNotNull { entity -> + decodedCache[entity.name]?.let { return@mapNotNull it } + try { - val gzippedInputStream = - GZIPInputStream(ByteArrayInputStream(entity.brushBytes)) - val rawProtoBytes = gzippedInputStream.readBytes() - val proto = ink.proto.BrushFamily.parseFrom(rawProtoBytes) - - proto.textureIdToBitmapMap.forEach { (id, byteString) -> - val bitmapBytes = byteString.toByteArray() - val bitmap = - BitmapFactory.decodeByteArray(bitmapBytes, 0, bitmapBytes.size) - if (bitmap != null) { - textureStore.loadTexture(id, bitmap) + GZIPInputStream(ByteArrayInputStream(entity.brushBytes)) + .use { gzip -> + val rawProtoBytes = gzip.readBytes() + val proto = ink.proto.BrushFamily.parseFrom(rawProtoBytes) + + proto.textureIdToBitmapMap.forEach { (id, byteString) -> + val bitmapBytes = byteString.toByteArray() + val bitmap = + BitmapFactory.decodeByteArray( + bitmapBytes, + 0, + bitmapBytes.size + ) + if (bitmap != null) { + textureStore.loadTexture(id, bitmap) + } + } } - } ByteArrayInputStream(entity.brushBytes).use { inputStream -> val family = BrushFamily.decode(inputStream) @@ -484,7 +497,7 @@ class DrawingCanvasViewModel @Inject constructor( icon = com.example.cahier.R.drawable.edit_24px, brushFamily = family, isRemovable = true - ) + ).also { decodedCache[entity.name] = it } } } catch (e: Exception) { Log.e(TAG, "Error loading textures/brush ${entity.name}", e) diff --git a/app/src/main/java/com/example/cahier/features/home/CahierApp.kt b/app/src/main/java/com/example/cahier/features/home/CahierApp.kt index 269e809..6050b6f 100644 --- a/app/src/main/java/com/example/cahier/features/home/CahierApp.kt +++ b/app/src/main/java/com/example/cahier/features/home/CahierApp.kt @@ -18,38 +18,15 @@ package com.example.cahier.features.home -import androidx.compose.foundation.background -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.captionBar -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.isCaptionBarVisible -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.windowInsetsTopHeight -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController -import com.example.cahier.R import com.example.cahier.core.data.NoteType import com.example.cahier.core.navigation.CahierNavHost import com.example.cahier.core.navigation.DrawingCanvasDestination import com.example.cahier.core.navigation.TextCanvasDestination -@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) @Composable fun CahierApp( noteId: Long, @@ -57,8 +34,6 @@ fun CahierApp( modifier: Modifier = Modifier, ) { val navController = rememberNavController() - val navBackStackEntry by navController.currentBackStackEntryAsState() - val currentRoute = navBackStackEntry?.destination?.route LaunchedEffect(noteId, noteType) { if (noteId > 0) { @@ -73,36 +48,8 @@ fun CahierApp( } } - Column(modifier = modifier.fillMaxSize()) { - if (WindowInsets.isCaptionBarVisible) { - val title = when (currentRoute) { - TextCanvasDestination.routeWithArgs -> stringResource(R.string.text_note) - DrawingCanvasDestination.routeWithArgs -> stringResource(R.string.drawing) - else -> stringResource(R.string.app_name) - } - Row( - modifier = Modifier - .windowInsetsTopHeight(WindowInsets.captionBar) - .fillMaxWidth() - .background( - if (isSystemInDarkTheme()) - MaterialTheme.colorScheme.surfaceContainerHigh - else MaterialTheme.colorScheme.secondaryContainer - ), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - title, - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(4.dp) - ) - } - } - - CahierNavHost( - navController = navController, - modifier = Modifier.weight(1f) - ) - } + CahierNavHost( + navController = navController, + modifier = modifier + ) } \ No newline at end of file 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 dc3b788..fa6b1cc 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 @@ -104,10 +104,12 @@ enum class AppDestinations( fun HomePane( navigateToCanvas: (Long) -> Unit, navigateToDrawingCanvas: (Long) -> Unit, + navigateToBrushDesigner: () -> Unit = {}, navigateUp: () -> Unit, modifier: Modifier = Modifier, - homeScreenViewModel: HomeScreenViewModel = hiltViewModel() -) { + forceCompact: Boolean? = null, + homeScreenViewModel: HomeScreenViewModel = hiltViewModel(), + ) { var currentDestination by rememberSaveable { mutableStateOf(AppDestinations.Home) } val navigator = rememberListDetailPaneScaffoldNavigator() val noteList by homeScreenViewModel.noteList.collectAsStateWithLifecycle() @@ -119,7 +121,8 @@ fun HomePane( var hasSetInitialProportion by remember { mutableStateOf(false) } - val isCompact = windowSizeClass?.widthSizeClass == WindowWidthSizeClass.Compact + val isCompact = forceCompact + ?: (windowSizeClass?.widthSizeClass == WindowWidthSizeClass.Compact) val context = LocalContext.current @@ -174,6 +177,7 @@ fun HomePane( selectedNoteUIState = selectedNoteUIState, navigateToCanvas = navigateToCanvas, navigateToDrawingCanvas = navigateToDrawingCanvas, + navigateToBrushDesigner = navigateToBrushDesigner, navigateUp = navigateUp ) } @@ -193,6 +197,7 @@ private fun CahierNavigationSuite( selectedNoteUIState: CahierUiState, navigateToCanvas: (Long) -> Unit, navigateToDrawingCanvas: (Long) -> Unit, + navigateToBrushDesigner: () -> Unit, navigateUp: () -> Unit ) { NavigationSuiteScaffold( @@ -306,6 +311,7 @@ private fun CahierNavigationSuite( AppDestinations.Settings -> { SettingsScreen( + navigateToBrushDesigner = navigateToBrushDesigner, 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 b586284..acfd078 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 @@ -26,20 +26,29 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -52,6 +61,7 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3AdaptiveApi::class, ExperimentalMaterial3Api::class) @Composable fun SettingsScreen( + navigateToBrushDesigner: () -> Unit, modifier: Modifier = Modifier, viewModel: SettingsViewModel = hiltViewModel() ) { @@ -77,42 +87,145 @@ fun SettingsScreen( Column( modifier = Modifier .fillMaxSize() - .padding(paddingValues), - horizontalAlignment = Alignment.Start, + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Text( - text = stringResource(R.string.notes_role_setting_title), - style = MaterialTheme.typography.titleLarge - ) - Text( - text = stringResource(R.string.notes_role_setting_description), - style = MaterialTheme.typography.bodyMedium - ) - - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = when { - !isRoleAvailable -> stringResource( - R.string.notes_role_not_available + // Constrain content width on wide screens + Column( + modifier = Modifier.widthIn(max = 600.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // ── Default Notes App ── + ElevatedCard( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + painterResource(R.drawable.settings_24px), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = stringResource(R.string.notes_role_setting_title), + style = MaterialTheme.typography.titleMedium + ) + } + Text( + text = stringResource(R.string.notes_role_setting_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant ) - isRoleHeld -> stringResource(R.string.notes_role_held) - else -> stringResource(R.string.notes_role_not_held) - }, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.weight(1f) - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = when { + !isRoleAvailable -> stringResource( + R.string.notes_role_not_available + ) + isRoleHeld -> stringResource(R.string.notes_role_held) + else -> stringResource(R.string.notes_role_not_held) + }, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f) + ) - Button( - onClick = { - coroutineScope.launch { - viewModel.requestNotesRole(requestRoleLauncher) + FilledTonalButton( + onClick = { + coroutineScope.launch { + viewModel.requestNotesRole(requestRoleLauncher) + } + }, + enabled = isRoleAvailable && !isRoleHeld + ) { + Text(stringResource(R.string.set_as_default_notes_app)) + } } - }, - enabled = isRoleAvailable && !isRoleHeld + } + } + + // ── Developer Tools ── + ElevatedCard( + modifier = Modifier.fillMaxWidth() ) { - Text(stringResource(R.string.set_as_default_notes_app)) + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + painterResource(R.drawable.brush_24px), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Column { + Text( + text = stringResource(R.string.settings_developer_tools), + style = MaterialTheme.typography.titleMedium + ) + Text( + text = stringResource(R.string.settings_developer_tools_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_brush_designer_title), + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = stringResource(R.string.settings_brush_designer_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + FilledTonalButton( + onClick = { navigateToBrushDesigner() } + ) { + Text(stringResource(R.string.settings_launch)) + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_node_graph_ui), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = stringResource(R.string.settings_node_graph_coming_soon), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = false, + onCheckedChange = null, + enabled = false + ) + } + } } } } diff --git a/app/src/main/res/drawable/arrow_back_24px.xml b/app/src/main/res/drawable/arrow_back_24px.xml new file mode 100644 index 0000000..0e2e863 --- /dev/null +++ b/app/src/main/res/drawable/arrow_back_24px.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/circle_24px.xml b/app/src/main/res/drawable/circle_24px.xml new file mode 100644 index 0000000..2922852 --- /dev/null +++ b/app/src/main/res/drawable/circle_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/content_copy_24px.xml b/app/src/main/res/drawable/content_copy_24px.xml new file mode 100644 index 0000000..90c1d08 --- /dev/null +++ b/app/src/main/res/drawable/content_copy_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/info_24px.xml b/app/src/main/res/drawable/info_24px.xml new file mode 100644 index 0000000..7eda45e --- /dev/null +++ b/app/src/main/res/drawable/info_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/opacity_24px.xml b/app/src/main/res/drawable/opacity_24px.xml new file mode 100644 index 0000000..552e296 --- /dev/null +++ b/app/src/main/res/drawable/opacity_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/remove_24px.xml b/app/src/main/res/drawable/remove_24px.xml new file mode 100644 index 0000000..606b875 --- /dev/null +++ b/app/src/main/res/drawable/remove_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/texture_24px.xml b/app/src/main/res/drawable/texture_24px.xml new file mode 100644 index 0000000..482e3b4 --- /dev/null +++ b/app/src/main/res/drawable/texture_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 60b232b..fd20ea3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -83,4 +83,184 @@ Image upload Do you want to replace the current image? + + Brush Designer + Close + Stock Brushes + My Palette + No saved brushes yet + Save to Palette + Import + Export + More options + Save to Cahier 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 + Paint + Behaviors + Brush Layers (Coats) + Coat %1$d + Add Layer + Delete Layer + Lock text fields + Client brush family ID + Developer comment + Input Model + Model Type + Spring Model + Experimental Naive Model + Sliding Window Model + Sliding Window Model (Default) + Name Texture + Texture ID (e.g. pattern_1) + Load + Cancel + Tip Geometry + Particle Settings (Stamps) + Paint & Texture + Dynamics & Behaviors + No behaviors defined. + Clear All Behaviors + Standard Dynamics + Add Advanced Dynamics + Invalid Brush Configuration\nCheck Constraints + Pick a color + Custom Color… + Import Texture from Gallery + Loaded Textures: %1$d + Textures + Color Functions + + + Decrease %1$s + Increase %1$s + Enter exact value + Lock Scale Ratio + + + Add %1$s + Duplicate + Delete + Editing Item %1$d + + + + Pressure Size + + Speed Size + + Speed affects Opacity + Smooth Pressure ➔ Size + Add Pencil Jitter (Slant) + Smooth Speed ➔ Opacity + Behavior Stack + Nodes in this Behavior: + + + Active Texture + Texture ID + No Texture Selected + Mapping Mode + Blend Mode + Self Overlap + Texture Layers + Paint Preferences + + + %1$d px + Pick a color + + + Source + Source Input + Range Start + Range End + Out of Range + Response Curve + Curve Type + Control X1 + Control Y1 + Control X2 + Control Y2 + Predefined Curve + Step Count + Damping (Smoothing) + Damping Source + Gap (seconds) + Noise (Jitter) + Seed + Vary Over + Base Period + Target + Target Output + Modifier Range Start + Modifier Range End + Constant Value + Value + Binary Operation + Operation + Interpolation + + + Animation + No color functions — add one below. + Opacity: %1$.1fx + Replace Color + Unknown + Size Unit + Scale X + Scale Y + Rotation + Origin + Offset X + Offset Y + Wrap X + Wrap Y + Empty + + + Black + Red + Blue + Green + Yellow + + + Rows + Columns + Frames + Duration (s) + Function Type + Opacity Multiplier + Window size (ms) + Upsampling frequency (Hz) + value + + + Scale X + Scale Y + Corner Rounding + Slant + Rotation + Pinch + Gap Distance Scale + Gap Duration (ms) + + + Texture: %1$s + Replace Color function — color source is controlled by a behavior node. + Unknown color function type. + + + Brushes + Stock + Cahier + + + Developer Tools + Experimental features and debugging tools. + Ink Brush Designer + Create, test, and export custom .brush files + Launch + Node Graph UI + Coming soon + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7d93555..8f9759d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,6 +21,7 @@ adaptiveNavigationAndroid = "1.2.0" navigationRuntimeKtx = "2.9.7" navigationCompose = "2.9.7" protobufJavalite = "4.34.0" +composeColorPickerAndroid = "0.7.0" roomKtx = "2.8.4" roomRuntime = "2.8.4" windowCore = "1.5.1" @@ -31,7 +32,7 @@ inputMotionprediction = "1.0.0" #Tests mockito = "5.23.0" -mockitoKotlin = "6.3.0" +mockitoKotlin = "6.2.3" coreTesting = "2.2.0" kotlinxCoroutinesTest = "1.10.2" turbine = "1.2.1" @@ -100,6 +101,7 @@ roborazzi = { group = "io.github.takahirom.roborazzi", name = "roborazzi", versi roborazzi-compose = { group = "io.github.takahirom.roborazzi", name = "roborazzi-compose", version.ref = "roborazzi" } roborazzi-rule = { group = "io.github.takahirom.roborazzi", name = "roborazzi-junit-rule", version.ref = "roborazzi" } protobuf-javalite = { module = "com.google.protobuf:protobuf-javalite", version.ref = "protobufJavalite" } +compose-color-picker-android = { module = "com.godaddy.android.colorpicker:compose-color-picker-android", version.ref = "composeColorPickerAndroid" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" }