From aa4c053d667e522a16c88bfdd15ad00046aadbc7 Mon Sep 17 00:00:00 2001 From: Patryk Goworowski Date: Sun, 15 Dec 2024 15:05:51 +0100 Subject: [PATCH] [WorkoutScreen] Display the exercise number and the completed sets info next to exercise names --- .../liftapp/core/text/MarkupProcessor.kt | 137 ++++++++++++++++++ .../liftapp/core/ui/theme/Theme.kt | 11 +- core/src/main/res/values/strings.xml | 3 + .../feature/workout/model/EditableWorkout.kt | 2 + .../feature/workout/ui/ExerciseListPicker.kt | 98 ++++++++++--- 5 files changed, 227 insertions(+), 24 deletions(-) create mode 100644 core/src/main/kotlin/com/patrykandpatryk/liftapp/core/text/MarkupProcessor.kt diff --git a/core/src/main/kotlin/com/patrykandpatryk/liftapp/core/text/MarkupProcessor.kt b/core/src/main/kotlin/com/patrykandpatryk/liftapp/core/text/MarkupProcessor.kt new file mode 100644 index 00000000..708fcfd9 --- /dev/null +++ b/core/src/main/kotlin/com/patrykandpatryk/liftapp/core/text/MarkupProcessor.kt @@ -0,0 +1,137 @@ +package com.patrykandpatryk.liftapp.core.text + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.em +import timber.log.Timber + +class MarkupProcessor(private val config: Config) { + @Stable + fun toAnnotatedString(text: String): AnnotatedString { + val strippedText = stripMarkupRegex.replace(text, "") + val indexLookup = IndexLookup.create(text, strippedText) + return buildAnnotatedString { + append(strippedText) + addMarkupStyles(text, indexLookup.provideIndexes()) + } + } + + private fun AnnotatedString.Builder.addMarkupStyles( + text: String, + indexProvider: IndexLookup.IndexProvider, + ) { + markupRegex.findAll(text).forEach { match -> + val (tag, content) = match.destructured + addStyle( + style = config.getSpanStyle(tag), + start = indexProvider[match.range.first], + end = indexProvider[match.range.last], + ) + addMarkupStyles( + text = content, + indexProvider = + indexProvider.withOffset(checkNotNull(match.groups.last()).range.first), + ) + } + } + + class Config(private val map: Map) { + fun getSpanStyle(tag: String): SpanStyle = + map.getOrElse(Type.forTag(tag)) { + Timber.w("No SpanStyle found for tag: $tag") + SpanStyle() + } + + companion object { + fun build(block: MutableMap.() -> Unit): Config = + Config(mutableMapOf().apply(block)) + } + } + + private class IndexLookup private constructor(private val map: Map) { + fun provideIndexes(offset: Int = 0): IndexProvider = + object : IndexProvider { + override fun withOffset(offset: Int): IndexProvider = provideIndexes(offset) + + override fun get(index: Int): Int = checkNotNull(map[index + offset]) + } + + interface IndexProvider { + operator fun get(index: Int): Int + + fun withOffset(offset: Int): IndexProvider + } + + companion object { + fun create(textWithMarkup: String, strippedText: String): IndexLookup { + var transformedIndex = 0 + return buildMap { + textWithMarkup.forEachIndexed { index, char -> + put(index, transformedIndex) + if (strippedText.getOrNull(transformedIndex) == char) { + transformedIndex++ + } + } + } + .let(::IndexLookup) + } + } + } + + enum class Type(val tag: String) { + Bold("b"), + BoldSurfaceColor("b-surface"), + Italic("i"), + Small("small"); + + companion object { + fun forTag(tag: String): Type = + checkNotNull(Type.entries.find { it.tag == tag }) { "No Type found for tag: $tag" } + } + } + + companion object { + private val markupRegex = Regex("""<(\w+[-\w+]*)>(.*?)""") + private val stripMarkupRegex = Regex("""<(/?\w+[-\w+]*)>""") + } +} + +val LocalMarkupProcessor: ProvidableCompositionLocal = staticCompositionLocalOf { + error("No MarkupProcessor provided") +} + +@Composable +fun rememberDefaultMarkupConfig(): MarkupProcessor.Config { + val colors = MaterialTheme.colorScheme + val typography = MaterialTheme.typography + + return remember(colors, typography) { + MarkupProcessor.Config.build { + put(MarkupProcessor.Type.Bold, SpanStyle(fontWeight = FontWeight.ExtraBold)) + put( + MarkupProcessor.Type.BoldSurfaceColor, + SpanStyle(fontWeight = FontWeight.ExtraBold, color = colors.onSurface), + ) + put(MarkupProcessor.Type.Italic, SpanStyle(fontStyle = FontStyle.Italic)) + put( + MarkupProcessor.Type.Small, + SpanStyle(fontSize = .7.em, color = colors.onSurfaceVariant), + ) + } + } +} + +@Composable +fun rememberDefaultMarkupProcessor(): MarkupProcessor { + val config = rememberDefaultMarkupConfig() + return remember(config) { MarkupProcessor(config) } +} diff --git a/core/src/main/kotlin/com/patrykandpatryk/liftapp/core/ui/theme/Theme.kt b/core/src/main/kotlin/com/patrykandpatryk/liftapp/core/ui/theme/Theme.kt index f1d41229..36c2e5b9 100644 --- a/core/src/main/kotlin/com/patrykandpatryk/liftapp/core/ui/theme/Theme.kt +++ b/core/src/main/kotlin/com/patrykandpatryk/liftapp/core/ui/theme/Theme.kt @@ -2,7 +2,6 @@ package com.patrykandpatryk.liftapp.core.ui.theme import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MaterialTheme.shapes import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable @@ -12,6 +11,8 @@ import com.patrykandpatrick.vico.compose.m3.style.m3ChartStyle import com.patrykandpatrick.vico.compose.style.ChartStyle import com.patrykandpatrick.vico.compose.style.ProvideChartStyle import com.patrykandpatryk.liftapp.core.extension.isLandscape +import com.patrykandpatryk.liftapp.core.text.LocalMarkupProcessor +import com.patrykandpatryk.liftapp.core.text.rememberDefaultMarkupProcessor import com.patrykandpatryk.liftapp.core.ui.dimens.LandscapeDimens import com.patrykandpatryk.liftapp.core.ui.dimens.LocalDimens import com.patrykandpatryk.liftapp.core.ui.dimens.PortraitDimens @@ -99,8 +100,12 @@ fun LiftAppTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composabl val dimens = if (isLandscape) LandscapeDimens else PortraitDimens - CompositionLocalProvider(LocalDimens provides dimens) { - MaterialTheme(colorScheme = colorScheme, typography = LiftAppTypography, shapes = Shapes) { + MaterialTheme(colorScheme = colorScheme, typography = LiftAppTypography, shapes = Shapes) { + val markupProcessor = rememberDefaultMarkupProcessor() + CompositionLocalProvider( + LocalDimens provides dimens, + LocalMarkupProcessor provides markupProcessor, + ) { ProvideChartStyle(chartStyle = chartStyle, content = content) } } diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index e3d35fcc..f16befd3 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -44,7 +44,10 @@ %1$s %2$s %1$s %2$s × %3$s %4$s + %1$s ꞏ %2$s ꞏ %3$s + + <b-surface>%1$d</b-surface> of <b-surface>%2$d</b-surface> %3$s Abductors Abs diff --git a/feature/workout/src/main/kotlin/com/patrykandpatrick/liftapp/feature/workout/model/EditableWorkout.kt b/feature/workout/src/main/kotlin/com/patrykandpatrick/liftapp/feature/workout/model/EditableWorkout.kt index f9a7669c..ce78e7d0 100644 --- a/feature/workout/src/main/kotlin/com/patrykandpatrick/liftapp/feature/workout/model/EditableWorkout.kt +++ b/feature/workout/src/main/kotlin/com/patrykandpatrick/liftapp/feature/workout/model/EditableWorkout.kt @@ -36,6 +36,8 @@ data class EditableWorkout( ) : Serializable { val firstIncompleteSetIndex: Int = sets.indexOfFirst { !it.isComplete } + val completedSetCount: Int = sets.count { it.isComplete } + @Stable fun isSetActive(set: EditableExerciseSet): Boolean = sets.indexOf(set) == firstIncompleteSetIndex diff --git a/feature/workout/src/main/kotlin/com/patrykandpatrick/liftapp/feature/workout/ui/ExerciseListPicker.kt b/feature/workout/src/main/kotlin/com/patrykandpatrick/liftapp/feature/workout/ui/ExerciseListPicker.kt index bfb65d93..4ffece60 100644 --- a/feature/workout/src/main/kotlin/com/patrykandpatrick/liftapp/feature/workout/ui/ExerciseListPicker.kt +++ b/feature/workout/src/main/kotlin/com/patrykandpatrick/liftapp/feature/workout/ui/ExerciseListPicker.kt @@ -1,26 +1,39 @@ package com.patrykandpatrick.liftapp.feature.workout.ui import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.util.lerp import com.patrykandpatrick.liftapp.feature.workout.model.EditableWorkout +import com.patrykandpatryk.liftapp.core.R import com.patrykandpatryk.liftapp.core.model.getDisplayName +import com.patrykandpatryk.liftapp.core.text.LocalMarkupProcessor import com.patrykandpatryk.liftapp.core.ui.BackdropState import com.patrykandpatryk.liftapp.core.ui.dimens.LocalDimens import com.patrykandpatryk.liftapp.core.ui.wheel.WheelPicker import com.patrykandpatryk.liftapp.core.ui.wheel.WheelPickerState import kotlin.math.abs +import kotlin.math.roundToInt @Composable fun ExerciseListPicker( @@ -32,7 +45,8 @@ fun ExerciseListPicker( WheelPicker( highlight = { Box( - Modifier.graphicsLayer { alpha = backdropState.offsetFraction } + Modifier + .graphicsLayer { alpha = backdropState.offsetFraction } .background( MaterialTheme.colorScheme.primaryContainer, RoundedCornerShape(8.dp), @@ -52,29 +66,71 @@ fun ExerciseListPicker( ) }, ) { - workout.exercises.forEach { exercise -> + val indexTextWidth = remember { mutableIntStateOf(0) } + val density = LocalDensity.current.density + + workout.exercises.forEachIndexed { index, exercise -> val positionOffset = remember { mutableFloatStateOf(1f) } - Text( - text = exercise.name.getDisplayName(), - style = MaterialTheme.typography.titleLarge, + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = + Arrangement.spacedBy(LocalDimens.current.padding.itemHorizontal), modifier = - Modifier.onPositionChange { offset, viewPortOffset -> - positionOffset.floatValue = abs(offset) - } - .graphicsLayer { - alpha = - if (positionOffset.floatValue == 0f || !backdropState.isOpened) { - 1f - } else { - backdropState.offsetFraction - } + Modifier + .onPositionChange { offset, viewPortOffset -> + positionOffset.floatValue = abs(offset) + } + .graphicsLayer { + alpha = + if (positionOffset.floatValue == 0f || !backdropState.isOpened) { + 1f + } else { + backdropState.offsetFraction + } + } + .fillMaxWidth() + .padding( + LocalDimens.current.padding.itemHorizontal, + LocalDimens.current.padding.itemVertical, + ), + ) { + Text( + text = "${index + 1}.", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = + Modifier + .onGloballyPositioned { layoutCoordinates -> + indexTextWidth.intValue = + (layoutCoordinates.size.width / density) + .roundToInt() + .coerceAtLeast(indexTextWidth.intValue) } - .fillMaxWidth() - .padding( - LocalDimens.current.padding.itemHorizontal, - LocalDimens.current.padding.itemVertical, - ), - ) + .widthIn(min = indexTextWidth.intValue.dp), + ) + + Text( + text = exercise.name.getDisplayName(), + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + Text( + text = + LocalMarkupProcessor.current.toAnnotatedString( + stringResource( + R.string.workout_exercise_list_set_format, + exercise.completedSetCount, + exercise.sets.size, + pluralStringResource(R.plurals.set_count, exercise.sets.size), + ) + ), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } }