diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 789299b1..05cacee1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,7 +26,7 @@ android { } lint { - isAbortOnError = false + abortOnError = false } kotlinOptions { @@ -53,7 +53,7 @@ android { } lint { - isAbortOnError = false + abortOnError = false } composeOptions { @@ -96,7 +96,8 @@ dependencies { with(Deps.Compose) { implementation(ui) implementation(uiGraphics) - implementation(uiTooling) + debugImplementation(uiTooling) + implementation(uiToolingPreview) implementation(foundationLayout) implementation(materialExtended) implementation(material) diff --git a/app/src/main/java/com/daniil/shevtsov/idle/application/IdleGameViewModel.kt b/app/src/main/java/com/daniil/shevtsov/idle/application/IdleGameViewModel.kt index 5b62ed54..d1b250bd 100644 --- a/app/src/main/java/com/daniil/shevtsov/idle/application/IdleGameViewModel.kt +++ b/app/src/main/java/com/daniil/shevtsov/idle/application/IdleGameViewModel.kt @@ -11,9 +11,9 @@ import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.scan -import timber.log.Timber import javax.inject.Inject import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds class IdleGameViewModel @Inject constructor( private val balanceConfig: BalanceConfig, @@ -32,7 +32,7 @@ class IdleGameViewModel @Inject constructor( private suspend fun startTime(until: Duration) { TimeBehavior.startEmitingTime( timeStorage = timeStorage, - interval = Duration.milliseconds(balanceConfig.tickRateMillis), + interval = balanceConfig.tickRateMillis.milliseconds, until = until, ) } @@ -44,7 +44,6 @@ class IdleGameViewModel @Inject constructor( .scan(0L to 0L) { previousPair, newTime -> previousPair.second to newTime } .map { (previous, new) -> val difference = new - previous - Timber.d("previous $previous new $new") Time(difference) } .onEach { time -> @@ -54,7 +53,6 @@ class IdleGameViewModel @Inject constructor( currentState.resources.find { it.key == ResourceKey.Blood }!!.value val resourceChange = oldResourceValue + time.value * balanceConfig.resourcePerMillisecond - Timber.d("time: $time oldResourceValue: $oldResourceValue balanceCOnfig: ${balanceConfig.resourcePerMillisecond} resource change: $resourceChange") imperativeShell.updateState( newState = currentState.copy( resources = currentState.resources.map { resource -> diff --git a/app/src/main/java/com/daniil/shevtsov/idle/feature/portrait/view/BezierExtensions.kt b/app/src/main/java/com/daniil/shevtsov/idle/feature/portrait/view/BezierExtensions.kt new file mode 100644 index 00000000..8cf8f631 --- /dev/null +++ b/app/src/main/java/com/daniil/shevtsov/idle/feature/portrait/view/BezierExtensions.kt @@ -0,0 +1,44 @@ +package com.daniil.shevtsov.idle.feature.portrait.view + +import androidx.compose.ui.geometry.Offset + +data class BezierState( + val start: Offset, + val support: Offset, + val support2: Offset? = null, + val finish: Offset, +) + +fun BezierState.points() = listOfNotNull( + start, + finish, + support, + support2, +) + +fun List.toBezierState() = BezierState( + start = get(0), + finish = get(1), + support = get(2), + support2 = getOrNull(3), +) + +fun BezierState.multiply( + x: Float = 1f, + y: Float = 1f +): BezierState = points().map { point -> + point.copy( + x = point.x.times(x), + y = point.y.times(y) + ) +}.toBezierState() + +fun BezierState.add( + x: Float = 0f, + y: Float = 0f +): BezierState = points().map { point -> + point.copy( + x = point.x + x, + y = point.y + y, + ) +}.toBezierState() diff --git a/app/src/main/java/com/daniil/shevtsov/idle/feature/portrait/view/Core.kt b/app/src/main/java/com/daniil/shevtsov/idle/feature/portrait/view/Core.kt new file mode 100644 index 00000000..4f182812 --- /dev/null +++ b/app/src/main/java/com/daniil/shevtsov/idle/feature/portrait/view/Core.kt @@ -0,0 +1,93 @@ +package com.daniil.shevtsov.idle.feature.portrait.view + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import kotlin.math.pow +import kotlin.math.sqrt +import kotlin.random.Random + +fun Rect.shrink(percent: Float): Rect { + return shrink( + widthPercent = percent, heightPercent = percent + ) +} + +enum class Anchor { + Center +} + +fun Rect.move( + position: Offset, + anchor: Anchor = Anchor.Center, +) = Rect( + offset = position.translate( + x = -width / 2, + y = -height / 2, + ), + size = size, +) + +fun Rect.shrink( + widthPercent: Float = 1f, + heightPercent: Float = 1f, +): Rect { + val newWidth = width * widthPercent + val newHeight = height * heightPercent + + return Rect( + offset = topLeft.translate( + x = (width - newWidth) / 2, + y = (height - newHeight) / 2, + ), + size = Size(newWidth, newHeight) + ) +} + +fun Offset.translate( + value: Float, +) = translate( + x = value, + y = value, +) + +fun Offset.translate( + x: Float = 0f, + y: Float = 0f, +) = copy( + x = this.x + x, + y = this.y + y, +) + +fun Offset.distanceTo(offset: Offset) = sqrt((offset.x - x).pow(2) + (offset.y - y).pow(2)) + +fun Offset.coerceIn(bounds: Rect) = Offset( + x = x.coerceIn(bounds.left, bounds.right), + y = y.coerceIn(bounds.top, bounds.bottom) +) + +fun Offset.times( + x: Float = 1f, + y: Float = 1f +) = Offset( + x = this.x * x, + y = this.y * y, +) + +fun Offset.div( + x: Float = 1f, + y: Float = 1f +) = Offset( + x = this.x / x, + y = this.y / y, +) + +fun Random.nextFloatInRange( + min: Float = Float.MIN_VALUE, + max: Float = Float.MAX_VALUE, +) = min + nextFloat() * (max - min) + +fun BodyPart.toRect() = Rect( + position, + size, +) diff --git a/app/src/main/java/com/daniil/shevtsov/idle/feature/portrait/view/DrawScopeExtensions.kt b/app/src/main/java/com/daniil/shevtsov/idle/feature/portrait/view/DrawScopeExtensions.kt new file mode 100644 index 00000000..3e6e06ac --- /dev/null +++ b/app/src/main/java/com/daniil/shevtsov/idle/feature/portrait/view/DrawScopeExtensions.kt @@ -0,0 +1,62 @@ +package com.daniil.shevtsov.idle.feature.portrait.view + +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke + +fun DrawScope.drawArea( + area: Rect, + color: Color = Color.Red, + strokeWidth: Float = 3f, +) { + drawRect( + color, + style = Stroke(width = strokeWidth), + topLeft = area.topLeft, + size = area.size + ) +} + +fun DrawScope.drawBodyPart(part: BodyPart) { + drawRect(part.color, topLeft = part.position, size = part.size) +} + +fun DrawScope.drawBezierPoints( + bezierState: BezierState, + pointColor: Color = Color.Green, + supportColor: Color = Color.Red, + pointRadius: Float = 16f, +) { + drawCircle(pointColor, center = bezierState.start, radius = pointRadius) + drawCircle(pointColor, center = bezierState.finish, radius = pointRadius) + drawCircle(supportColor, center = bezierState.support, radius = pointRadius) + if (bezierState.support2 != null) { + drawCircle(supportColor, center = bezierState.support2, radius = pointRadius) + } +} + +fun Path.drawQuadraticBezier(state: BezierState) { + with(state) { + moveTo(start.x, start.y) + if (state.support2 == null) { + quadraticBezierTo( + support.x, + support.y, + finish.x, + finish.y, + ) + } else { + cubicTo( + support.x, + support.y, + support2!!.x, + support2.y, + finish.x, + finish.y, + ) + } + + } +} diff --git a/app/src/main/java/com/daniil/shevtsov/idle/feature/portrait/view/Portrait.kt b/app/src/main/java/com/daniil/shevtsov/idle/feature/portrait/view/Portrait.kt new file mode 100644 index 00000000..decc3845 --- /dev/null +++ b/app/src/main/java/com/daniil/shevtsov/idle/feature/portrait/view/Portrait.kt @@ -0,0 +1,115 @@ +package com.daniil.shevtsov.idle.feature.portrait.view + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daniil.shevtsov.idle.feature.portrait.view.face.drawNose +import com.daniil.shevtsov.idle.feature.portrait.view.face.drawPortrait + +@Preview( + widthDp = 800, + heightDp = 800 +) +@Composable +fun PortraitPreview() { + val previewSize = 400.dp + + val previewMode = PreviewMode.Portaits + + val state = PreviewState( + mode = previewMode, + shouldShowFaceAreas = false, + shouldShowNoseAreas = false, + shouldShowEyeAreas = false, + ) + + when (state.mode) { + PreviewMode.Nose -> { + Canvas(modifier = Modifier, onDraw = { + val nose = BodyPart( + position = Offset(size.width / 2 - size.width / 4, 0f), + size = Size(size.width / 2, size.height), + color = Color.Gray + ) + drawNose(nose, state) + }) + } + PreviewMode.Portaits -> { + Column { + repeat(2) { + Row { + repeat(2) { + Portrait( + previewState = state, + modifier = Modifier.size(previewSize) + ) + } + } + } + } + } + } +} + +@Composable +fun Portrait( + previewState: PreviewState, + modifier: Modifier = Modifier, +) { + Canvas(modifier = modifier, onDraw = { + drawPortrait(previewState) + }) +} + +data class GeneratingConfig( + val faceArea: Rect, + val eyesArea: Rect, + val noseArea: Rect, + val mouthArea: Rect, +) + +data class BodyPart( + val position: Offset, + val size: Size, + val color: Color, +) + +data class FacePartsSize( + val eye: Size, + val nose: Size, + val mouth: Size, +) + +data class PortraitState( + val head: BodyPart, + val leftEye: BodyPart, + val rightEye: BodyPart, + val nose: BodyPart, + val mouth: BodyPart, +) + + +enum class PreviewMode { + Portaits, + Nose, +} + +data class PreviewState( + val mode: PreviewMode, + val shouldShowFaceAreas: Boolean, + val shouldShowNoseAreas: Boolean, + val shouldShowEyeAreas: Boolean, +) + + + + diff --git a/app/src/main/java/com/daniil/shevtsov/idle/feature/portrait/view/face/Eye.kt b/app/src/main/java/com/daniil/shevtsov/idle/feature/portrait/view/face/Eye.kt new file mode 100644 index 00000000..7b2740d7 --- /dev/null +++ b/app/src/main/java/com/daniil/shevtsov/idle/feature/portrait/view/face/Eye.kt @@ -0,0 +1,140 @@ +package com.daniil.shevtsov.idle.feature.portrait.view.face + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.ClipOp +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daniil.shevtsov.idle.feature.portrait.view.* + +data class EyeConfig( + val iris: Float, + val pupil: Float, + val squint: Float, +) + +@Preview +@Composable +fun EyePreview() { + val config = EyeConfig( + iris = 0.3f, + pupil = 0.7f, + squint = 0.2f + ) + Canvas(modifier = Modifier.size(400.dp), onDraw = { + drawEye( + eyeArea = Rect( + offset = Offset(0f, 0f), + size = size + ), + config = config, + ) + }) +} + +fun DrawScope.drawEye( + eyeArea: Rect, + config: EyeConfig, + shouldShowAreas: Boolean = true, +) { + val eyeLidSize = Size( + width = eyeArea.width, + height = (eyeArea.height / 2f) * config.squint, + ) + val topEyeLidArea = Rect( + offset = eyeArea.topLeft, + size = eyeLidSize + ) + val bottomEyeLidArea = Rect( + offset = eyeArea.bottomLeft.translate(y = -eyeLidSize.height), + size = eyeLidSize + ) + + val center = eyeArea.center + val eyeSize = eyeArea.size + val eye = BodyPart( + position = center.translate( + x = -eyeSize.width / 2f, + y = -eyeSize.height / 2f, + ), + size = eyeSize, + color = Color.White + ) + + val irisSize = Size( + (eye.size.height + eye.size.width) / 2f, + (eye.size.height + eye.size.width) / 2f + ) * config.iris + val iris = BodyPart( + position = eye.position.translate( + x = eye.size.width / 2f - irisSize.width / 2f, + y = eye.size.height / 2f - irisSize.height / 2f, + ), + size = irisSize, + color = Color.Green, + ) + val pupilSize = iris.toRect() + .shrink( + percent = config.pupil + ).size + val pupil = BodyPart( + position = iris.toRect().center.translate( + x = -pupilSize.width / 2, + y = -pupilSize.height / 2, + ), + size = pupilSize, + color = Color.Black + ) + + val pupilLightSize = pupil.toRect().shrink(percent = 0.3f).size + val pupilLight = BodyPart( + position = pupil.toRect().center.translate( + x = -pupilLightSize.width / 2f, + y = -pupilLightSize.height / 2f, + ), + size = pupilLightSize, + color = Color.White + ) + + val eyelidHackSize = Size( + width = eye.size.width, + height = eye.size.height - eyeLidSize.height * 2 + ) + val eyeLid = BodyPart( + position = eye.toRect().centerLeft.translate(y = -eyelidHackSize.height / 2f), + size = eyelidHackSize, + color = Color.Blue, + ) + val circlePath = Path().apply { + addOval(Rect(eyeLid.position, eyeLid.size)) + } + clipPath(circlePath, clipOp = ClipOp.Intersect) { + drawEyePart(eye) + drawEyePart(iris) + drawEyePart(pupil) + drawEyePart(pupilLight) + } + + if (shouldShowAreas) { + drawArea(eyeArea) + drawArea(topEyeLidArea) + drawArea(bottomEyeLidArea) + } +} + +fun DrawScope.drawEyePart(part: BodyPart) { + drawOval( + part.color, + topLeft = part.position, + size = part.size, + ) +} diff --git a/app/src/main/java/com/daniil/shevtsov/idle/feature/portrait/view/face/Head.kt b/app/src/main/java/com/daniil/shevtsov/idle/feature/portrait/view/face/Head.kt new file mode 100644 index 00000000..28c7a10d --- /dev/null +++ b/app/src/main/java/com/daniil/shevtsov/idle/feature/portrait/view/face/Head.kt @@ -0,0 +1,340 @@ +package com.daniil.shevtsov.idle.feature.portrait.view.face + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.material.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.consumeAllChanges +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.daniil.shevtsov.idle.feature.portrait.view.* +import timber.log.Timber + +data class HeadViewState( + val topArea: BezierState, + val bottomArea: BezierState, + val previousSelectedIndex: Int, +) + +private fun Modifier.bezierDragging( + percentageStateValue: HeadViewState, + screenBounds: Rect, + updateState: (newState: HeadViewState) -> Unit, +) = composed { + val percentageStateValue by rememberUpdatedState(percentageStateValue) + + pointerInput(Unit) { + detectDragGestures( + onDragStart = { + Timber.tag("UPDATE-INDEX").d("Drag started") + updateState( + percentageStateValue.copy( + previousSelectedIndex = -1 + ) + ) + }, + onDragEnd = { + Timber.tag("UPDATE-INDEX").d("Drag ended") + updateState( + percentageStateValue.copy( + previousSelectedIndex = -1 + ) + ) + }, + onDragCancel = { + Timber.tag("UPDATE-INDEX").d("Drag cancelled") + updateState( + percentageStateValue.copy( + previousSelectedIndex = -1 + ) + ) + } + ) { change, dragAmount -> + change.consumeAllChanges() + + + updateState( + doEverything( + percentageStateValue, + screenBounds, + change, + dragAmount + ) + ) + } + } +} + +fun doEverything( + percentageStateValue: HeadViewState, + screenBounds: Rect, + change: PointerInputChange, + dragAmount: Offset, +): HeadViewState { + val kek = mapOf( + "top" to percentageStateValue.topArea, + "bottom" to percentageStateValue.bottomArea, + ) + val kekPoints = kek + .toList() + .flatMap { (name, area) -> + area.points() + .map { point -> name to point } + } + .mapIndexed { index, (name, point) -> index to (name to point) } + .toMap() + + val previousSelectedPointIndex = percentageStateValue.previousSelectedIndex + val originalState = percentageStateValue.topArea + val oldPoints = kekPoints.toList().map { (index, pointEntry) -> + val (name, point) = pointEntry + index to (name to point.times( + x = screenBounds.width, + y = screenBounds.height, + )) + } + + val clickAreaLimit = 0.1f * screenBounds.height + + val selectedPointIndex: Int = when { + previousSelectedPointIndex != -1 -> { + Timber.tag("KEK").d("Reuse position $previousSelectedPointIndex") + previousSelectedPointIndex + } + else -> { + Timber.tag("UPDATE-INDEX") + .d(" index is $previousSelectedPointIndex Choose nearest") + oldPoints + .onEach { (index, pointEntry) -> + val (name, point) = pointEntry + Timber.d("point $index $name $point has distance ${point.distanceTo(change.position)} to ${change.position} and limit is $clickAreaLimit") + } + .filter { (index, pointEntry) -> + val (name, point) = pointEntry + point.distanceTo(change.position) <= clickAreaLimit + } + .minByOrNull { (index, pointEntry) -> + val (name, point) = pointEntry + point.distanceTo(change.position) + }?.let { (index, _) -> index } + } + } ?: return percentageStateValue + + val selectedPoint = oldPoints.getOrNull(selectedPointIndex) + if (selectedPoint != null) { + val newPoints = oldPoints.map { (index, pointEntry) -> + val (name, point) = pointEntry + if (index == oldPoints.indexOf(selectedPoint)) { + name to point.translate( + x = dragAmount.x, + y = dragAmount.y, + ) + } else { + name to point + } + } + val coercedPoints = newPoints.map { (name, point) -> + name to point.coerceIn(screenBounds) + } + val finalPoints = coercedPoints.map { (name, point) -> + name to point.div( + x = screenBounds.width, + y = screenBounds.height + ) + } + + return percentageStateValue.copy( + topArea = finalPoints + .filter { (name, _) -> name == "top" }.map { (_, point) -> point } + .toBezierState(), + bottomArea = finalPoints + .filter { (name, _) -> name == "bottom" }.map { (_, point) -> point } + .toBezierState(), + previousSelectedIndex = selectedPointIndex, + ) + } else { + return percentageStateValue + } +} + +@Composable +fun HeadPreviewComposable() { + var state by remember { + mutableStateOf( + HeadViewState( + topArea = BezierState( + start = Offset(0f, 0.5f), + finish = Offset(1f, 0.5f), + support = Offset(0f, 0f), + support2 = Offset(1f, 0f), + ), + bottomArea = BezierState( + start = Offset(0f, 0.5f), + finish = Offset(1f, 0.5f), + support = Offset(0f, 1f), + support2 = Offset(1f, 1f), + ), + previousSelectedIndex = -1, + ) + ) + } + Column(modifier = Modifier.background(Color.White)) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + state.topArea.points().forEachIndexed { index, point -> + val title = when (index) { + 0 -> "Start" + 1 -> "Finish" + 2 -> "Support1" + 3 -> "Support2" + else -> "Unknown" + } + Text(text = "$title: $point", fontSize = 18.sp) + } + } + Box( + modifier = Modifier.size(800.dp).background(Color.White), + contentAlignment = Alignment.Center + ) { + val canvasSize = with(LocalDensity.current) { 400.dp.toPx() } + val canvasBounds = Rect( + offset = Offset.Zero, + size = Size(canvasSize, canvasSize) + ) + Canvas( + modifier = Modifier + .size(400.dp) + .background(Color.LightGray) + .bezierDragging( + percentageStateValue = state, + screenBounds = canvasBounds, + updateState = { newState -> + state = newState + } + ), + onDraw = { + val headArea = Rect( + offset = center.translate(-size.height / 2f), + size = size, + ) + drawRect( + Color.DarkGray, + topLeft = center.translate(-size.width / 2f, -size.height / 2f), + size = size + ) + drawHead( + headArea = headArea, + state = state, + onStateChanged = { newPoints -> + state = state.copy( + topArea = newPoints + ) + } + ) + }) + } + } +} + +@Preview( + widthDp = 800, + heightDp = 800, +) +@Composable +fun HeadPreview() { + HeadPreviewComposable() +// DraggingComposable() +} + +fun DrawScope.drawHead( + headArea: Rect, + state: HeadViewState, + onStateChanged: (state: BezierState) -> Unit, +) { + val topHeadArea = headArea + .shrink(heightPercent = 0.33f) + .let { area -> area.move(position = headArea.topCenter.translate(y = area.height / 2f)) } + val middleArea = headArea + .shrink(heightPercent = 0.33f) + .let { area -> area.move(position = topHeadArea.bottomCenter.translate(y = area.height / 2f)) } + val bottomArea = headArea + .shrink(heightPercent = 0.34f) + .let { area -> area.move(position = middleArea.bottomCenter.translate(y = area.height / 2f)) } + + val topAreaInPixels = state.topArea.multiply( + x = headArea.width, + y = headArea.height, + ).add( + x = topHeadArea.left, + y = headArea.top + ) + val bottomAreaInPixels = state.bottomArea.multiply( + x = headArea.width, + y = headArea.height, + ).add( + x = bottomArea.left, + y = headArea.top + ) + + drawPath( + path = Path().apply { + drawQuadraticBezier(topAreaInPixels) + }, + color = Color.Cyan, + style = Fill, + ) + drawPath( + path = Path().apply { + drawQuadraticBezier(bottomAreaInPixels) + }, + color = Color.Yellow, + style = Stroke(width = 3f), + ) + drawPath( + path = Path().apply { + drawQuadraticBezier(bottomAreaInPixels) + }, + color = Color.Cyan, + style = Fill, + ) + drawPath( + path = Path().apply { + moveTo(topAreaInPixels.start.x, topAreaInPixels.start.y) + lineTo(topAreaInPixels.finish.x, topAreaInPixels.finish.y) + lineTo(bottomAreaInPixels.finish.x, bottomAreaInPixels.finish.y) + lineTo(bottomAreaInPixels.start.x, bottomAreaInPixels.start.y) + lineTo(topAreaInPixels.start.x, topAreaInPixels.start.y) + }, + color = Color.Cyan, + style = Fill, + ) + + drawArea(headArea) + drawArea(topHeadArea) + drawArea(middleArea) + drawArea(bottomArea) + + drawBezierPoints(topAreaInPixels) + drawBezierPoints(bottomAreaInPixels) + + +} diff --git a/app/src/main/java/com/daniil/shevtsov/idle/feature/portrait/view/face/Nose.kt b/app/src/main/java/com/daniil/shevtsov/idle/feature/portrait/view/face/Nose.kt new file mode 100644 index 00000000..4ffb13c3 --- /dev/null +++ b/app/src/main/java/com/daniil/shevtsov/idle/feature/portrait/view/face/Nose.kt @@ -0,0 +1,146 @@ +package com.daniil.shevtsov.idle.feature.portrait.view.face + +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.DrawScope +import com.daniil.shevtsov.idle.feature.portrait.view.* +import kotlin.random.Random + +fun DrawScope.drawNose( + nose: BodyPart, + previewState: PreviewState, +) { + val noseArea = nose.toRect() + val dorsumArea = noseArea + .shrink( + widthPercent = Random.nextFloatInRange( + min = 0.3f, + max = 0.8f, + ) + ) + val nostrilsArea = noseArea + .shrink(heightPercent = 0.25f) + .let { + it.move( + nose.position.translate( + x = it.size.width / 2, + y = nose.size.height - it.size.height + ) + ) + } + val tipArea = Rect( + dorsumArea.bottomLeft.translate( + y = -(dorsumArea.bottomLeft.y - nostrilsArea.bottomLeft.y) + ), + Size( + width = dorsumArea.width, + height = dorsumArea.bottomLeft.y - nostrilsArea.bottomLeft.y + ) + ) + val dorsumSize = Size( + dorsumArea.width, + dorsumArea.height - tipArea.height + ) + val dorsum = BodyPart( + position = dorsumArea.topCenter.translate( + x = -dorsumSize.width / 2f + ), + size = dorsumSize, + color = Color.LightGray + ) + + val leftNostrilArea = Rect( + nostrilsArea.topLeft, + Size( + width = (dorsumArea.left - nostrilsArea.left), + height = nostrilsArea.height, + ) + ) + val rightNostrilArea = Rect( + nostrilsArea.topRight.translate( + x = -(nostrilsArea.right - dorsumArea.right) + ), + Size( + width = nostrilsArea.right - dorsumArea.right, + height = nostrilsArea.height, + ) + ) + val nostrilsSupportY = Random.nextFloatInRange( + min = 0f, + max = nostrilsArea.height + ) + val nostrilWidth = Random.nextFloatInRange( + min = (nostrilsArea.width - dorsumArea.width) / 8f, + max = (nostrilsArea.width - dorsumArea.width) / 2f, + ) + + val leftNostril = BezierState( + start = leftNostrilArea.topRight, + finish = leftNostrilArea.bottomRight, + support = leftNostrilArea.topRight + .translate( + x = -nostrilWidth * 2, + y = nostrilsSupportY, + ), + ) + + val rightNostril = BezierState( + start = rightNostrilArea.topLeft, + finish = rightNostrilArea.bottomLeft, + support = rightNostrilArea.topLeft + .translate( + x = nostrilWidth * 2, + y = nostrilsSupportY + ), + ) + + val tipHeight = Random.nextFloatInRange( + min = tipArea.height * 0.1f, + max = tipArea.height, + ) + val noseTip = BezierState( + start = tipArea.topLeft, + finish = tipArea.topRight, + support = tipArea.topCenter.translate(y = tipHeight * 2) + ) + + drawPath( + path = Path().apply { + drawQuadraticBezier(rightNostril) + close() + }, + Color.LightGray, + ) + + drawPath( + path = Path().apply { + drawQuadraticBezier(leftNostril) + close() + }, + Color.LightGray, + ) + + drawPath( + path = Path().apply { + drawQuadraticBezier(noseTip) + close() + }, + Color.LightGray, + ) + drawBodyPart(dorsum) + + if (previewState.shouldShowNoseAreas) { + drawArea(noseArea) + drawArea(dorsumArea) + drawArea(nostrilsArea) + drawArea(leftNostrilArea) + drawArea(rightNostrilArea) + drawArea(tipArea) + drawBezierPoints(leftNostril) + drawBezierPoints(rightNostril) + drawBezierPoints(noseTip) + } +} + diff --git a/app/src/main/java/com/daniil/shevtsov/idle/feature/portrait/view/face/Portrait.kt b/app/src/main/java/com/daniil/shevtsov/idle/feature/portrait/view/face/Portrait.kt new file mode 100644 index 00000000..bfd804c9 --- /dev/null +++ b/app/src/main/java/com/daniil/shevtsov/idle/feature/portrait/view/face/Portrait.kt @@ -0,0 +1,243 @@ +package com.daniil.shevtsov.idle.feature.portrait.view.face + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daniil.shevtsov.idle.feature.portrait.view.* +import kotlin.random.Random + +@Preview( + widthDp = 800, + heightDp = 800 +) +@Composable +fun PortraitPreview() { + val previewSize = 400.dp + + val previewMode = PreviewMode.Portaits + + val state = PreviewState( + mode = previewMode, + shouldShowFaceAreas = true, + shouldShowNoseAreas = false, + shouldShowEyeAreas = false, + ) + Column { + repeat(2) { + Row { + repeat(2) { + Portrait( + previewState = state, + modifier = androidx.compose.ui.Modifier.size(previewSize) + ) + } + } + } + } +} + +fun DrawScope.drawPortrait( + previewState: PreviewState +) { + val screenArea = Rect(Offset(0f, 0f), Offset(size.width, size.height)) + + val headArea = screenArea + .shrink(0.8f) + + + val headSize = Size( + width = Random.nextFloatInRange( + min = headArea.width * 0.4f, + max = headArea.width + ), + height = Random.nextFloatInRange( + min = headArea.height * 0.4f, + max = headArea.height + ), + ) + val head = BodyPart( + position = headArea.center.translate( + x = -headSize.width / 2, + y = -headSize.height / 2, + ), + size = headSize, + color = Color.Gray + ) + + val faceArea = Rect( + head.position, + head.size, + ) + + val eyesArea = faceArea + .shrink( + widthPercent = 0.75f, + heightPercent = 0.3f, + ) + .move(faceArea.center.translate(y = -faceArea.height * (1 / 8f))) + val eyeArea = eyesArea + .shrink(widthPercent = 0.5f, heightPercent = 1f) + .move(eyesArea.center.translate(x = -eyesArea.width * 0.25f)) + + + val noseArea = faceArea + .shrink( + widthPercent = 0.35f, + heightPercent = 0.4f + ) + .move(position = faceArea.center.translate(y = faceArea.height * (1 / 8f))) + val mouthArea = faceArea + .shrink( + widthPercent = 0.75f, + heightPercent = 0.2f + ) + .move(faceArea.bottomCenter.translate(y = -faceArea.height * (1 / 8f))) + + + val axisSize = 5f + val verticalAxis = Rect( + faceArea.topCenter.copy(x = faceArea.topCenter.x - axisSize), + faceArea.bottomCenter.copy(x = faceArea.bottomCenter.x + axisSize), + ) + + val generatedSizes = FacePartsSize( + eye = Size( + width = Random.nextFloatInRange( + min = eyeArea.width * 0.4f, + max = eyeArea.width * 0.9f + ), + height = Random.nextFloatInRange( + min = eyeArea.height * 0.3f, + max = eyeArea.height * 0.6f + ), + ), + nose = Size( + width = Random.nextFloatInRange( + min = noseArea.width * 0.5f, + max = noseArea.width + ), + height = Random.nextFloatInRange( + min = noseArea.height * 0.5f, + max = noseArea.height + ), + ), + mouth = Size( + width = Random.nextFloatInRange( + min = mouthArea.width * 0.3f, + max = mouthArea.width + ), + height = Random.nextFloatInRange( + min = mouthArea.height * 0.1f, + max = mouthArea.height * 0.6f + ) + ), + ) + + val eyeY = Random.nextFloatInRange(max = eyeArea.height - generatedSizes.eye.height) + val eyeDistance = + Random.nextFloatInRange(max = (eyesArea.width - generatedSizes.eye.width * 2) + 5f) + val portraitState = PortraitState( + head = head, + leftEye = BodyPart( + position = eyesArea.topCenter.translate( + x = (-eyeDistance / 2) - generatedSizes.eye.width, + y = eyeY + ), + size = generatedSizes.eye, + color = Color.Green + ), + rightEye = BodyPart( + position = eyesArea.topCenter.translate( + x = eyeDistance / 2, + y = eyeY + ), + size = generatedSizes.eye, + color = Color.Green + ), + nose = BodyPart( + position = noseArea.topCenter.translate( + x = -generatedSizes.nose.width / 2, + y = Random.nextFloatInRange(max = noseArea.height - generatedSizes.nose.height) + ), + size = generatedSizes.nose, + color = Color.LightGray + ), + mouth = BodyPart( + position = verticalAxis.bottomCenter.translate( + x = -generatedSizes.mouth.width / 2, + y = -verticalAxis.size.height * 0.10f - generatedSizes.mouth.height / 2, + ), + size = generatedSizes.mouth, + color = Color.White + ), + ) + + drawRect(Color.DarkGray, topLeft = screenArea.topLeft, size = screenArea.size) + drawBodyPart(portraitState.head) + + val eyeConfig = EyeConfig( + iris = Random.nextFloatInRange( + min = 0.4f, + max = 0.7f, + ), + pupil = Random.nextFloatInRange( + min = 0.6f, + max = 0.8f, + ), + squint = Random.nextFloatInRange( + min = 0.2f, + max = 0.6f, + ), + ) + + drawHead( + headArea = faceArea, + state = HeadViewState( + topArea = BezierState( + start = Offset(0f, 0.5f), + finish = Offset(1f, 0.5f), + support = Offset(0f, 0f), + support2 = Offset(1f, 0f), + ), + bottomArea = BezierState( + start = Offset(0f, 0.5f), + finish = Offset(1f, 0.5f), + support = Offset(0f, 1f), + support2 = Offset(1f, 1f), + ), + previousSelectedIndex = -1, + ), + onStateChanged = {}, + ) + + drawEye( + eyeArea = portraitState.leftEye.toRect(), + config = eyeConfig, + shouldShowAreas = previewState.shouldShowEyeAreas + ) + drawEye( + eyeArea = portraitState.rightEye.toRect(), + config = eyeConfig, + shouldShowAreas = previewState.shouldShowEyeAreas + ) + drawBodyPart(portraitState.mouth) + + drawNose(portraitState.nose, previewState = previewState) + + if (previewState.shouldShowFaceAreas) { + drawArea(headArea) + drawArea(faceArea) + drawArea(eyesArea) + drawArea(eyeArea) + drawArea(noseArea) + drawArea(mouthArea) + } +} diff --git a/app/src/main/java/com/daniil/shevtsov/idle/feature/time/domain/TimeBehavior.kt b/app/src/main/java/com/daniil/shevtsov/idle/feature/time/domain/TimeBehavior.kt index 51778ec3..736a82f9 100644 --- a/app/src/main/java/com/daniil/shevtsov/idle/feature/time/domain/TimeBehavior.kt +++ b/app/src/main/java/com/daniil/shevtsov/idle/feature/time/domain/TimeBehavior.kt @@ -6,8 +6,8 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.takeWhile -import timber.log.Timber import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds object TimeBehavior { suspend fun startEmitingTime( @@ -19,8 +19,7 @@ object TimeBehavior { .takeWhile { duration -> duration <= until } .collect { duration -> val passed = duration.inWholeMilliseconds / interval.inWholeMilliseconds - Timber.d("duration ${duration.inWholeMicroseconds} interval ${interval.inWholeMilliseconds}") - timeStorage.setNewValue(Duration.milliseconds(passed)) + timeStorage.setNewValue(passed.milliseconds) } } @@ -29,7 +28,7 @@ object TimeBehavior { } private fun timerFlow(interval: Duration): Flow = flow { - var elapsedTime = Duration.milliseconds(0.0) + var elapsedTime = 0.0.milliseconds while (true) { emit(elapsedTime) elapsedTime += interval diff --git a/app/src/test/java/com/daniil/shevtsov/idle/feature/portrait/view/PortaitComposableKtTest.kt b/app/src/test/java/com/daniil/shevtsov/idle/feature/portrait/view/PortaitComposableKtTest.kt new file mode 100644 index 00000000..8620ba70 --- /dev/null +++ b/app/src/test/java/com/daniil/shevtsov/idle/feature/portrait/view/PortaitComposableKtTest.kt @@ -0,0 +1,64 @@ +package com.daniil.shevtsov.idle.feature.portrait.view + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import assertk.all +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.prop +import org.junit.jupiter.api.Test + +internal class PortaitComposableKtTest { + + @Test + fun `should translate correctly`() { + val original = Offset(100f, 200f) + val translated = original.translate( + x = 100f, + y = 200f, + ) + assertThat(translated) + .isEqualTo(Offset(200f, 400f)) + } + + @Test + fun `should shrink rect correctly`() { + val original = Rect( + topLeft = Offset(100f, 200f), + bottomRight = Offset(300f, 500f), + ) + + val translated = original.shrink( + widthPercent = 0.25f, + heightPercent = 0.1f, + ) + + assertThat(translated) + .all { + prop(Rect::topLeft).isEqualTo(Offset(150f, 230f)) + prop(Rect::topRight).isEqualTo(Offset(250f, 230f)) + prop(Rect::bottomLeft).isEqualTo(Offset(150f, 470f)) + prop(Rect::bottomRight).isEqualTo(Offset(250f, 470f)) + } + } + + @Test + fun `should move rect correctly`() { + val original = Rect( + topLeft = Offset(0f, 0f), + bottomRight = Offset(400f, 600f), + ) + + val moved = original.move( + position = Offset(500f, 500f), + anchor = Anchor.Center + ) + + assertThat(moved) + .all { + prop(Rect::topLeft).isEqualTo(Offset(300f, 200f)) + prop(Rect::bottomRight).isEqualTo(Offset(700f, 800f)) + } + } + +} diff --git a/build.gradle.kts b/build.gradle.kts index b75a0866..bb791c87 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,7 +9,7 @@ buildscript { dependencies { // keeping this here to allow AS to automatically update - classpath("com.android.tools.build:gradle:7.0.3") + classpath("com.android.tools.build:gradle:7.2.1") with(Deps.Gradle) { // classpath(composeMultiplatform) @@ -32,4 +32,4 @@ allprojects { maven(url = "https://jitpack.io") maven(url = "https://dl.bintray.com/korlibs/korlibs") } -} \ No newline at end of file +} diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index f0b52496..05641994 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -7,10 +7,10 @@ object Versions { const val accompanist = "0.20.2" - const val compose = "1.0.5" + const val compose = "1.1.1" const val composeMultiplatform = "1.0.0-alpha08" - const val dagger = "2.36" + const val dagger = "2.42" const val jupiter = "5.7.2" const val junit = "4.13" @@ -18,7 +18,7 @@ object Versions { const val kBigNum = "2.2.0" - const val kotlin = "1.5.31" + const val kotlin = "1.6.10" const val kotlinCoroutines = "1.5.2-native-mt" const val koin = "3.1.1" const val kotlinterGradle = "3.4.5" @@ -65,6 +65,7 @@ object Deps { const val ui = "androidx.compose.ui:ui:${Versions.compose}" const val uiGraphics = "androidx.compose.ui:ui-graphics:${Versions.compose}" const val uiTooling = "androidx.compose.ui:ui-tooling:${Versions.compose}" + const val uiToolingPreview = "androidx.compose.ui:ui-tooling-preview:${Versions.compose}" const val foundationLayout = "androidx.compose.foundation:foundation-layout:${Versions.compose}" const val material = "androidx.compose.material:material:${Versions.compose}" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index c9dc05c6..ddcd619e 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Tue Jun 22 16:21:51 MSK 2021 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-7.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-all.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME