Skip to content
Merged
2 changes: 2 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ dependencies {
implementation(libs.androidx.adaptive)
implementation(libs.androidx.adaptive.layout)
implementation(libs.androidx.adaptive.navigation)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)

// Patch annotations + processor
implementation(project(":patch-annotations"))
Expand Down
81 changes: 40 additions & 41 deletions app/src/main/java/de/berlindroid/zepatch/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -51,17 +51,15 @@ import androidx.compose.material3.adaptive.navigation.NavigableListDetailPaneSca
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.embroidermodder.punching.Histogram
import de.berlindroid.zepatch.PatchablePreviewMode.BITMAP
import de.berlindroid.zepatch.PatchablePreviewMode.COMPOSABLE
Expand All @@ -75,9 +73,9 @@ import de.berlindroid.zepatch.ui.theme.ZePatchTheme
import de.berlindroid.zepatch.utils.multiLet
import de.berlindroid.zepatch.utils.uppercaseWords
import kotlinx.coroutines.launch
import androidx.lifecycle.viewmodel.compose.viewModel as lifecycleViewModel


private enum class PatchablePreviewMode {
enum class PatchablePreviewMode {
COMPOSABLE, BITMAP, REDUCED_BITMAP, STITCHES
}

Expand Down Expand Up @@ -197,18 +195,20 @@ private fun PatchableDetail(
onBackClick: () -> Unit,
patchable: @Composable (Boolean, (ImageBitmap) -> Unit) -> Unit,
) {
var imageBitmap by remember { mutableStateOf<ImageBitmap?>(null) }
var reducedImageBitmap by remember { mutableStateOf<ImageBitmap?>(null) }
var reducedHistogram by remember { mutableStateOf<Histogram?>(null) }
var colorCount by remember { mutableIntStateOf(3) }
var embroideryData by remember { mutableStateOf<ByteArray?>(null) }
var embroideryPreviewImage by remember { mutableStateOf<ImageBitmap?>(null) }

val context = LocalContext.current

val viewModel: WizardViewModel = lifecycleViewModel()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()

// Reset wizard state when a different patchable is opened
LaunchedEffect(name) {
viewModel.reset()
}

val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
savePesAfterSelection(context, result, embroideryData)
savePesAfterSelection(context, result, uiState.embroideryData)
}

Scaffold(
Expand All @@ -232,38 +232,33 @@ private fun PatchableDetail(
Column(
modifier = Modifier.padding(innerPadding),
) {
var currentMode by remember { mutableStateOf(COMPOSABLE) }

ProgressPills(imageBitmap, reducedImageBitmap, currentMode)
ProgressPills(uiState.imageBitmap, uiState.reducedImageBitmap, uiState.previewMode)

WizardContent(
currentMode,
imageBitmap,
colorCount,
reducedImageBitmap,
reducedHistogram,
uiState.previewMode,
uiState.imageBitmap,
uiState.colorCount,
uiState.reducedImageBitmap,
uiState.reducedHistogram,
name,
onBitmapUpdated = { imageBitmap = it },
onColorCountUpdated = { colorCount = it },
onReducedUpdated = { img, histo ->
reducedImageBitmap = img;reducedHistogram = histo
},
onEmbroideryUpdated = { data, preview ->
embroideryData = data
embroideryPreviewImage = preview
},
patchable
onBitmapUpdated = viewModel::updateBitmap,
onColorCountUpdated = viewModel::updateColorCount,
computeReducedBitmap = viewModel::computeReducedBitmap,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit pick: should that also be onComputeReducedBitmap? Consistency with the other names, or does work differently, hence the difference in naming?

@maiatoday maiatoday Sep 7, 2025

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for onBitmapUpdated and onColorCountUpdated we ask the composable to do something and it calls these methods when it completes the task.

on the other hand the computeReducedBitmap is an imperative action which we are asking and external function to perform, in this case the ViewModel.

BUT I see a nit myself, I am not consistently using color vs colour. very irritating, I will fix it. Fake news it is ok

patchable = patchable
)

WizardButtons(
currentMode,
imageBitmap,
reducedImageBitmap,
embroideryData,
uiState.previewMode,
uiState.imageBitmap,
uiState.reducedImageBitmap,
uiState.embroideryData,
name,
launcher
) {
currentMode = it
viewModel.setPreviewMode(it)
if (it == REDUCED_BITMAP) {
viewModel.computeReducedBitmap()
}
}
}
}
Expand All @@ -279,8 +274,7 @@ private fun WizardContent(
name: String,
onBitmapUpdated: (ImageBitmap) -> Unit,
onColorCountUpdated: (Int) -> Unit,
onReducedUpdated: (ImageBitmap, Histogram) -> Unit,
onEmbroideryUpdated: (ByteArray, ImageBitmap) -> Unit,
computeReducedBitmap: () -> Unit,
patchable: @Composable (Boolean, (ImageBitmap) -> Unit) -> Unit,
) {
Card(
Expand All @@ -301,15 +295,20 @@ private fun WizardContent(
image = imageBitmap,
colorCount = colorCount,
onColorCountChanged = onColorCountUpdated,
onReduced = onReducedUpdated,
computeReducedBitmap = computeReducedBitmap,
reducedImage = reducedImageBitmap,
)

STITCHES -> reducedImageBitmap?.multiLet(reducedHistogram) { img, histo ->
val vm: WizardViewModel = lifecycleViewModel()
val ui by vm.uiState.collectAsStateWithLifecycle()
BitmapToStitches(
reducedImageBitmap = img,
reducedHistogram = histo,
name = name,
onEmbroidery = onEmbroideryUpdated
onCreateEmbroidery = { n, b, h -> vm.createEmbroidery(n, b, h) },
previewImage = ui.embroideryPreviewImage,
creatingEmbroidery = ui.creatingEmbroidery,
)
}
}
Expand Down
139 changes: 139 additions & 0 deletions app/src/main/java/de/berlindroid/zepatch/WizardViewModel.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package de.berlindroid.zepatch

import android.app.Application
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.core.graphics.scale
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.embroidermodder.punching.Histogram
import com.embroidermodder.punching.reduceColors
import de.berlindroid.zepatch.PatchablePreviewMode.COMPOSABLE
import de.berlindroid.zepatch.stiches.StitchToPES
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch

/**
* ViewModel to manage the Wizard screen state.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧙‍♀️👍

*/

class WizardViewModel(application: Application) : AndroidViewModel(application) {

data class UIState(
val imageBitmap: ImageBitmap? = null,
val reducedImageBitmap: ImageBitmap? = null,
val reducedHistogram: Histogram? = null,
val colorCount: Int = 3,
val embroideryData: ByteArray? = null,
val embroideryPreviewImage: ImageBitmap? = null,
val creatingEmbroidery: Boolean = false,
val error: String? = null,
val previewMode: PatchablePreviewMode = COMPOSABLE,
)

private val _uiState = MutableStateFlow(UIState())
val uiState: StateFlow<UIState> = _uiState.asStateFlow()

/** Resets the whole wizard state back to initial defaults. */
fun reset() {
_uiState.update { UIState() }
}

fun setPreviewMode(mode: PatchablePreviewMode) {
_uiState.update { it.copy(previewMode = mode) }
}

fun updateColorCount(count: Int) {
_uiState.update { it.copy(colorCount = count) }
}

fun updateBitmap(bitmap: ImageBitmap) {
_uiState.update {
it.copy(
imageBitmap = bitmap,
// Reset downstream results when source bitmap changes
reducedImageBitmap = null,
reducedHistogram = null,
embroideryData = null,
embroideryPreviewImage = null,
)
}
}

fun computeReducedBitmap() {
val state = _uiState.value
val image = state.imageBitmap ?: return
val colorCount = state.colorCount
viewModelScope.launch(Dispatchers.IO) {
val aspect = image.width / image.height.toFloat()
val (reducedBmp, histogram) = image.asAndroidBitmap()
.copy(Bitmap.Config.ARGB_8888, false)
.scale((512 * aspect).toInt(), 512, false)
.reduceColors(colorCount)

_uiState.update {
it.copy(
reducedImageBitmap = reducedBmp.asImageBitmap(),
reducedHistogram = histogram,
)
}
}
}

fun createEmbroidery(
name: String,
bitmap: ImageBitmap,
histogram: Histogram,
) {
viewModelScope.launch(Dispatchers.IO) {
try {
_uiState.update { it.copy(creatingEmbroidery = true, error = null) }

val aspect = bitmap.width / bitmap.height.toFloat()
val embroidery = StitchToPES.createEmbroideryFromBitmap(
name = name,
bitmap = bitmap.asAndroidBitmap(),
histogram = histogram,
mmWidth = 500f * aspect,
mmHeight = 500f,
mmDensityX = 4f,
mmDensityY = 2f,
)

val context = getApplication<Application>().applicationContext
val pes = StitchToPES.convert(context, embroidery)
val png = StitchToPES.convert(context, embroidery, "png")

val previewImage = if (png == null || png.isEmpty()) {
null
} else {
val decoded = BitmapFactory.decodeByteArray(png, 0, png.size)
decoded.scale(decoded.width * 2, decoded.height * 2).asImageBitmap()
}

_uiState.update {
it.copy(
embroideryData = pes,
embroideryPreviewImage = previewImage,
creatingEmbroidery = false,
)
}
} catch (t: Throwable) {
_uiState.update {
it.copy(
creatingEmbroidery = false,
error = t.message ?: t.toString(),
)
}
}
}
}

}
Loading