From 7ec8cf934ae5cbbc161d27c24d1b45b3642f12ac Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Sun, 21 May 2023 22:20:43 +0300 Subject: [PATCH] Interactive formatting settings --- .../sadellie/unitto/core/base/Precision.kt | 25 -- core/base/src/main/res/values/strings.xml | 7 +- .../unitto/core/ui/common/SegmentedButton.kt | 16 +- .../unitto/core/ui/common/UnittoSlider.kt | 157 ++++++++++++ feature/settings/build.gradle.kts | 1 + .../unitto/feature/settings/SettingsScreen.kt | 72 +----- .../feature/settings/SettingsViewModel.kt | 27 -- .../settings/formatting/FormattingScreen.kt | 242 ++++++++++++++++++ .../settings/formatting/FormattingUIState.kt | 26 ++ .../formatting/FormattingViewModel.kt | 113 ++++++++ .../settings/navigation/SettingsNavigation.kt | 10 +- 11 files changed, 568 insertions(+), 128 deletions(-) create mode 100644 core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoSlider.kt create mode 100644 feature/settings/src/main/java/com/sadellie/unitto/feature/settings/formatting/FormattingScreen.kt create mode 100644 feature/settings/src/main/java/com/sadellie/unitto/feature/settings/formatting/FormattingUIState.kt create mode 100644 feature/settings/src/main/java/com/sadellie/unitto/feature/settings/formatting/FormattingViewModel.kt diff --git a/core/base/src/main/java/com/sadellie/unitto/core/base/Precision.kt b/core/base/src/main/java/com/sadellie/unitto/core/base/Precision.kt index cc627f53..f929479d 100644 --- a/core/base/src/main/java/com/sadellie/unitto/core/base/Precision.kt +++ b/core/base/src/main/java/com/sadellie/unitto/core/base/Precision.kt @@ -22,28 +22,3 @@ package com.sadellie.unitto.core.base * Current maximum scale that will be used in app. Used in various place in code */ const val MAX_PRECISION: Int = 1_000 - -/** - * Currently available scale options - */ -val PRECISIONS: Map by lazy { - mapOf( - 0 to R.string.precision_zero, - 1 to R.string.precision_one, - 2 to R.string.precision_two, - 3 to R.string.precision_three, - 4 to R.string.precision_four, - 5 to R.string.precision_five, - 6 to R.string.precision_six, - 7 to R.string.precision_seven, - 8 to R.string.precision_eight, - 9 to R.string.precision_nine, - 10 to R.string.precision_ten, - 11 to R.string.precision_eleven, - 12 to R.string.precision_twelve, - 13 to R.string.precision_thirteen, - 14 to R.string.precision_fourteen, - 15 to R.string.precision_fifteen, - MAX_PRECISION to R.string.max_precision - ) -} diff --git a/core/base/src/main/res/values/strings.xml b/core/base/src/main/res/values/strings.xml index 0fcd661b..da26f6b7 100644 --- a/core/base/src/main/res/values/strings.xml +++ b/core/base/src/main/res/values/strings.xml @@ -1258,6 +1258,7 @@ Third party licenses Rate this app Formatting + Precision and numbers appearance Additional @@ -1306,9 +1307,9 @@ Group separator symbol - Period (42.069,12) - Comma (42,069.12) - Spaces (42 069.12) + Period + Comma + Spaces Result value formatting diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/SegmentedButton.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/SegmentedButton.kt index 79d19737..58195781 100644 --- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/SegmentedButton.kt +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/SegmentedButton.kt @@ -66,7 +66,7 @@ fun RowScope.SegmentedButton( label: String, onClick: () -> Unit, selected: Boolean, - icon: ImageVector + icon: ImageVector? = null ) { val containerColor = if (selected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface @@ -81,14 +81,16 @@ fun RowScope.SegmentedButton( ), contentPadding = PaddingValues(horizontal = 12.dp) ) { - Crossfade(targetState = selected) { - if (it) { - Icon(Icons.Default.Check, null, Modifier.size(18.dp)) - } else { - Icon(icon, null, Modifier.size(18.dp)) + if (icon != null) { + Crossfade(targetState = selected) { + if (it) { + Icon(Icons.Default.Check, null, Modifier.size(18.dp)) + } else { + Icon(icon, null, Modifier.size(18.dp)) + } } + Spacer(Modifier.width(8.dp)) } - Spacer(Modifier.width(8.dp)) Text(label) } } diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoSlider.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoSlider.kt new file mode 100644 index 00000000..9ccbe731 --- /dev/null +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoSlider.kt @@ -0,0 +1,157 @@ +/* + * Unitto is a unit converter for Android + * Copyright (c) 2023 Elshan Agaev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.sadellie.unitto.core.ui.common + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderPositions +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.ClipOp +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.math.ceil +import kotlin.math.roundToInt + +@Composable +fun UnittoSlider( + modifier: Modifier = Modifier, + value: Float, + valueRange: ClosedFloatingPointRange, + onValueChange: (Float) -> Unit, + onValueChangeFinished: (Float) -> Unit = {} +) { + val animated = animateFloatAsState(targetValue = value) + + Slider( + value = animated.value, + onValueChange = onValueChange, + modifier = modifier, + valueRange = valueRange, + onValueChangeFinished = { onValueChangeFinished(animated.value) }, + track = { sliderPosition -> SquigglyTrack(sliderPosition) }, + steps = valueRange.endInclusive.roundToInt(), + ) +} + +@Composable +private fun SquigglyTrack( + sliderPosition: SliderPositions, + eachWaveWidth: Float = 80f, + strokeWidth: Float = 15f, + filledColor: Color = MaterialTheme.colorScheme.primary, + unfilledColor: Color = MaterialTheme.colorScheme.surfaceVariant +) { + val coroutineScope = rememberCoroutineScope() + var direct by remember { mutableStateOf(1f) } + val animatedDirect = animateFloatAsState(direct, spring()) + val slider = sliderPosition.activeRange.endInclusive + + LaunchedEffect(sliderPosition.activeRange.endInclusive) { + coroutineScope.launch { + delay(300L) + direct = if (direct == 1f) -1f else 1f + } + } + + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(20.dp) + ) { + val width = size.width + val height = size.height + + val path = Path().apply { + moveTo( + x = strokeWidth / 2, + y = height.times(0.5f) + ) + val amount = ceil(width.div(eachWaveWidth)) + + repeat(amount.toInt()) { + val peek = if (it % 2 == 0) animatedDirect.value else -animatedDirect.value + + relativeQuadraticBezierTo( + dx1 = eachWaveWidth * 0.5f, + // 0.75, because 1.0 was clipping out of bound for some reason + dy1 = height.times(0.75f) * peek, + dx2 = eachWaveWidth, + dy2 = 0f + ) + } + } + + clipRect( + top = 0f, + left = 0f, + right = width.times(slider), + bottom = height, + clipOp = ClipOp.Intersect + ) { + drawPath( + path = path, + color = filledColor, + style = Stroke(strokeWidth, cap = StrokeCap.Round) + ) + } + + drawLine( + color = unfilledColor, + start = Offset(width.times(slider), height.times(0.5f)), + end = Offset(width, height.times(0.5f)), + strokeWidth = strokeWidth, + cap = StrokeCap.Round + ) + } +} + +@Preview(device = "spec:width=411dp,height=891dp") +@Preview(device = "spec:width=673.5dp,height=841dp,dpi=480") +@Preview(device = "spec:width=1280dp,height=800dp,dpi=480") +@Preview(device = "spec:width=1920dp,height=1080dp,dpi=480") +@Composable +private fun PreviewNewSlider() { + var currentValue by remember { mutableStateOf(0.9f) } + + UnittoSlider( + value = currentValue, + valueRange = 0f..1f, + onValueChange = { currentValue = it } + ) +} diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index a8aa51e5..6c71d480 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -31,6 +31,7 @@ dependencies { implementation(libs.com.github.sadellie.themmo) implementation(libs.org.burnoutcrew.composereorderable) + implementation(project(mapOf("path" to ":data:common"))) implementation(project(mapOf("path" to ":data:model"))) implementation(project(mapOf("path" to ":data:unitgroups"))) implementation(project(mapOf("path" to ":data:userprefs"))) diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsScreen.kt index 1064c342..194cd602 100644 --- a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsScreen.kt @@ -19,7 +19,6 @@ package com.sadellie.unitto.feature.settings import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Home @@ -42,14 +41,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.sadellie.unitto.core.base.BuildConfig -import com.sadellie.unitto.core.base.OUTPUT_FORMAT -import com.sadellie.unitto.core.base.PRECISIONS import com.sadellie.unitto.core.base.R -import com.sadellie.unitto.core.base.SEPARATORS import com.sadellie.unitto.core.base.TOP_LEVEL_DESTINATIONS import com.sadellie.unitto.core.ui.common.Header import com.sadellie.unitto.core.ui.common.MenuButton @@ -59,6 +54,7 @@ import com.sadellie.unitto.core.ui.openLink import com.sadellie.unitto.data.model.UnitsListSorting import com.sadellie.unitto.feature.settings.components.AlertDialogWithList import com.sadellie.unitto.feature.settings.navigation.aboutRoute +import com.sadellie.unitto.feature.settings.navigation.formattingRoute import com.sadellie.unitto.feature.settings.navigation.themesRoute import com.sadellie.unitto.feature.settings.navigation.unitsGroupRoute @@ -110,43 +106,18 @@ internal fun SettingsScreen( ) } - // GENERAL GROUP - item { Header(stringResource(R.string.formatting_settings_group)) } - - // PRECISION + // FORMATTING item { ListItem( leadingContent = { Icon( Icons.Default._123, - stringResource(R.string.precision_setting), + stringResource(R.string.formatting_settings_group), ) }, - headlineContent = { Text(stringResource(R.string.precision_setting)) }, - supportingContent = { Text(stringResource(R.string.precision_setting_support)) }, - modifier = Modifier.clickable { dialogState = DialogState.PRECISION } - ) - } - - // SEPARATOR - item { - ListItem( - headlineContent = { Text(stringResource(R.string.separator_setting)) }, - supportingContent = { Text(stringResource(R.string.separator_setting_support)) }, - modifier = Modifier - .clickable { dialogState = DialogState.SEPARATOR } - .padding(start = 40.dp) - ) - } - - // OUTPUT FORMAT - item { - ListItem( - headlineContent = { Text(stringResource(R.string.output_format_setting)) }, - supportingContent = { Text(stringResource(R.string.output_format_setting_support)) }, - modifier = Modifier - .clickable { dialogState = DialogState.OUTPUT_FORMAT } - .padding(start = 40.dp) + headlineContent = { Text(stringResource(R.string.formatting_settings_group)) }, + supportingContent = { Text(stringResource(R.string.formatting_settings_support)) }, + modifier = Modifier.clickable { navControllerAction(formattingRoute) } ) } @@ -260,35 +231,6 @@ internal fun SettingsScreen( // Showing dialog when (dialogState) { - DialogState.PRECISION -> { - AlertDialogWithList( - title = stringResource(R.string.precision_setting), - listItems = PRECISIONS, - selectedItemIndex = userPrefs.value.digitsPrecision, - selectAction = viewModel::updatePrecision, - dismissAction = { resetDialog() }, - supportText = stringResource(R.string.precision_setting_info) - ) - } - DialogState.SEPARATOR -> { - AlertDialogWithList( - title = stringResource(R.string.separator_setting), - listItems = SEPARATORS, - selectedItemIndex = userPrefs.value.separator, - selectAction = viewModel::updateSeparator, - dismissAction = { resetDialog() } - ) - } - DialogState.OUTPUT_FORMAT -> { - AlertDialogWithList( - title = stringResource(R.string.output_format_setting), - listItems = OUTPUT_FORMAT, - selectedItemIndex = userPrefs.value.outputFormat, - selectAction = viewModel::updateOutputFormat, - dismissAction = { resetDialog() }, - supportText = stringResource(R.string.output_format_setting_info) - ) - } DialogState.START_SCREEN -> { AlertDialogWithList( title = stringResource(R.string.starting_screen_setting), @@ -321,5 +263,5 @@ internal fun SettingsScreen( * All possible states for alert dialog that opens when user clicks on settings. */ private enum class DialogState { - NONE, PRECISION, SEPARATOR, OUTPUT_FORMAT, START_SCREEN, UNIT_LIST_SORTING + NONE, START_SCREEN, UNIT_LIST_SORTING } diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsViewModel.kt index b0aef490..d2f891c6 100644 --- a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsViewModel.kt @@ -40,33 +40,6 @@ class SettingsViewModel @Inject constructor( UserPreferences() ) - /** - * @see UserPreferencesRepository.updateDigitsPrecision - */ - fun updatePrecision(precision: Int) { - viewModelScope.launch { - userPrefsRepository.updateDigitsPrecision(precision) - } - } - - /** - * @see UserPreferencesRepository.updateSeparator - */ - fun updateSeparator(separator: Int) { - viewModelScope.launch { - userPrefsRepository.updateSeparator(separator) - } - } - - /** - * @see UserPreferencesRepository.updateOutputFormat - */ - fun updateOutputFormat(outputFormat: Int) { - viewModelScope.launch { - userPrefsRepository.updateOutputFormat(outputFormat) - } - } - /** * @see UserPreferencesRepository.updateVibrations */ diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/formatting/FormattingScreen.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/formatting/FormattingScreen.kt new file mode 100644 index 00000000..a799cecd --- /dev/null +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/formatting/FormattingScreen.kt @@ -0,0 +1,242 @@ +/* + * Unitto is a unit converter for Android + * Copyright (c) 2023 Elshan Agaev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.sadellie.unitto.feature.settings.formatting + +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +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.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Architecture +import androidx.compose.material.icons.filled.EMobiledata +import androidx.compose.material.icons.filled._123 +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +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.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sadellie.unitto.core.base.OUTPUT_FORMAT +import com.sadellie.unitto.core.base.OutputFormat +import com.sadellie.unitto.core.base.R +import com.sadellie.unitto.core.base.SEPARATORS +import com.sadellie.unitto.core.base.Separator +import com.sadellie.unitto.core.ui.common.NavigateUpButton +import com.sadellie.unitto.core.ui.common.UnittoSlider +import com.sadellie.unitto.core.ui.common.SegmentedButton +import com.sadellie.unitto.core.ui.common.SegmentedButtonsRow +import com.sadellie.unitto.core.ui.common.UnittoScreenWithLargeTopBar +import com.sadellie.unitto.core.ui.common.squashable +import com.sadellie.unitto.core.ui.theme.NumbersTextStyleDisplayMedium +import kotlin.math.roundToInt + +@Composable +fun FormattingRoute( + viewModel: FormattingViewModel = hiltViewModel(), + navigateUpAction: () -> Unit, +) { + val uiState = viewModel.uiState.collectAsStateWithLifecycle() + + FormattingScreen( + navigateUpAction = navigateUpAction, + uiState = uiState.value, + onPrecisionChange = viewModel::updatePrecision, + onSeparatorChange = viewModel::updateSeparator, + onOutputFormatChange = viewModel::updateOutputFormat, + togglePreview = viewModel::togglePreview + ) +} + +@Composable +fun FormattingScreen( + navigateUpAction: () -> Unit, + uiState: FormattingUIState, + onPrecisionChange: (Int) -> Unit, + onSeparatorChange: (Int) -> Unit, + onOutputFormatChange: (Int) -> Unit, + togglePreview: () -> Unit, + precisions: ClosedFloatingPointRange = 0f..16f, // 16th is a MAX_PRECISION (1000) +) { + UnittoScreenWithLargeTopBar( + title = stringResource(R.string.formatting_settings_group), + navigationIcon = { NavigateUpButton(navigateUpAction) }, + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .padding(paddingValues) + ) { + item("preview") { + Column( + Modifier + .padding(16.dp) + .squashable( + onClick = togglePreview, + cornerRadiusRange = 8.dp..32.dp, + interactionSource = remember { MutableInteractionSource() } + ) + .background(MaterialTheme.colorScheme.secondaryContainer) + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = "Preview (click to switch)", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + Text( + text = uiState.preview, + style = NumbersTextStyleDisplayMedium, + maxLines = 1, + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + textAlign = TextAlign.End, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } + + item("precision_label") { + ListItem( + leadingContent = { + Icon(Icons.Default.Architecture, stringResource(R.string.precision_setting)) + }, + headlineContent = { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.precision_setting)) + Text(if (uiState.precision >= precisions.endInclusive) stringResource(R.string.max_precision) else uiState.precision.toString()) + } + }, + supportingContent = { + Text(stringResource(R.string.precision_setting_support)) + } + ) + } + + item("precision_slider") { + UnittoSlider( + modifier = Modifier.padding(start = 56.dp, end = 16.dp), + value = uiState.precision.toFloat(), + valueRange = precisions, + onValueChange = { onPrecisionChange(it.roundToInt()) }, + ) + } + + item("separator_label") { + ListItem( + leadingContent = { + Icon(Icons.Default._123, stringResource(R.string.precision_setting)) + }, + headlineContent = { Text(stringResource(R.string.separator_setting)) }, + supportingContent = { Text(stringResource(R.string.separator_setting_support)) }, + ) + } + + item("separator") { + Row( + Modifier + .horizontalScroll(rememberScrollState()) + .wrapContentWidth() + .padding(start = 56.dp) + ) { + SegmentedButtonsRow { + SEPARATORS.forEach { (separator, stringRes) -> + SegmentedButton( + label = stringResource(stringRes), + onClick = { onSeparatorChange(separator) }, + selected = separator == uiState.separator + ) + } + } + } + } + + item("output_format_label") { + ListItem( + leadingContent = { + Icon(Icons.Default.EMobiledata, stringResource(R.string.precision_setting)) + }, + headlineContent = { Text(stringResource(R.string.output_format_setting)) }, + supportingContent = { Text(stringResource(R.string.output_format_setting_support)) } + ) + } + + item("output_format") { + Row( + Modifier + .horizontalScroll(rememberScrollState()) + .wrapContentWidth() + .padding(start = 56.dp) + ) { + SegmentedButtonsRow { + OUTPUT_FORMAT.forEach { (outputFormat, stringRes) -> + SegmentedButton( + label = stringResource(stringRes), + onClick = { onOutputFormatChange(outputFormat) }, + selected = outputFormat == uiState.outputFormat + ) + } + } + } + } + } + } +} + +@Preview +@Composable +private fun PreviewFormattingScreen() { + var currentPrecision by remember { mutableStateOf(6) } + var currentSeparator by remember { mutableStateOf(Separator.COMMA) } + var currentOutputFormat by remember { mutableStateOf(OutputFormat.PLAIN) } + + FormattingScreen( + uiState = FormattingUIState( + preview = "", + precision = 3, + separator = Separator.SPACES, + outputFormat = OutputFormat.PLAIN + ), + onPrecisionChange = { currentPrecision = it }, + onSeparatorChange = { currentSeparator = it }, + onOutputFormatChange = { currentOutputFormat = it }, + navigateUpAction = {}, + togglePreview = {} + ) +} diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/formatting/FormattingUIState.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/formatting/FormattingUIState.kt new file mode 100644 index 00000000..0038e373 --- /dev/null +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/formatting/FormattingUIState.kt @@ -0,0 +1,26 @@ +/* + * Unitto is a unit converter for Android + * Copyright (c) 2023 Elshan Agaev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.sadellie.unitto.feature.settings.formatting + +data class FormattingUIState( + val preview: String = "", + val precision: Int = 0, + val separator: Int? = null, + val outputFormat: Int? = null +) diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/formatting/FormattingViewModel.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/formatting/FormattingViewModel.kt new file mode 100644 index 00000000..8dd66aa8 --- /dev/null +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/formatting/FormattingViewModel.kt @@ -0,0 +1,113 @@ +/* + * Unitto is a unit converter for Android + * Copyright (c) 2023 Elshan Agaev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.sadellie.unitto.feature.settings.formatting + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.sadellie.unitto.core.base.MAX_PRECISION +import com.sadellie.unitto.core.ui.common.textfield.AllFormatterSymbols +import com.sadellie.unitto.core.ui.common.textfield.formatExpression +import com.sadellie.unitto.data.common.setMinimumRequiredScale +import com.sadellie.unitto.data.common.toStringWith +import com.sadellie.unitto.data.common.trimZeros +import com.sadellie.unitto.data.userprefs.UserPreferencesRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.math.BigDecimal +import javax.inject.Inject +import kotlin.math.ceil + +@HiltViewModel +class FormattingViewModel @Inject constructor( + private val userPreferencesRepository: UserPreferencesRepository +) : ViewModel() { + private val _mainPreferences = userPreferencesRepository.mainPreferencesFlow + private val _fractional = MutableStateFlow(false) + + val uiState = combine(_mainPreferences, _fractional) { mainPrefs, fractional -> + + return@combine FormattingUIState( + preview = updatePreview( + fractional = fractional, + precision = mainPrefs.digitsPrecision, + outputFormat = mainPrefs.outputFormat, + separator = mainPrefs.separator + ), + precision = mainPrefs.digitsPrecision, + separator = mainPrefs.separator, + outputFormat = mainPrefs.outputFormat + ) + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), FormattingUIState()) + + fun togglePreview() = _fractional.update { !it } + + private fun updatePreview( + fractional: Boolean, + precision: Int, + outputFormat: Int, + separator: Int, + ): String { + val bigD = when { + fractional -> "0.${"0000001".padStart(precision, '0')}" + precision > 0 -> "123456.${"7890123456".repeat(ceil(precision.toDouble() / 10.0).toInt())}" + else -> "123456" + } + + return BigDecimal(bigD) + .setMinimumRequiredScale(precision) + .trimZeros() + .toStringWith(outputFormat) + .formatExpression(AllFormatterSymbols.getById(separator)) + } + + /** + * @see UserPreferencesRepository.updateDigitsPrecision + */ + fun updatePrecision(precision: Int) { + viewModelScope.launch { + // In UI the slider for precision goes from 0 to 16, where 16 is treated as 1000 (MAX) + val newPrecision = if (precision > 15) MAX_PRECISION else precision + userPreferencesRepository.updateDigitsPrecision(newPrecision) + } + } + + /** + * @see UserPreferencesRepository.updateSeparator + */ + fun updateSeparator(separator: Int) { + viewModelScope.launch { + userPreferencesRepository.updateSeparator(separator) + } + } + + /** + * @see UserPreferencesRepository.updateOutputFormat + */ + fun updateOutputFormat(outputFormat: Int) { + viewModelScope.launch { + userPreferencesRepository.updateOutputFormat(outputFormat) + } + } +} diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/navigation/SettingsNavigation.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/navigation/SettingsNavigation.kt index dc1e855b..1d14d759 100644 --- a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/navigation/SettingsNavigation.kt +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/navigation/SettingsNavigation.kt @@ -27,8 +27,9 @@ import androidx.navigation.compose.navigation import com.sadellie.unitto.core.base.TopLevelDestinations import com.sadellie.unitto.feature.settings.AboutScreen import com.sadellie.unitto.feature.settings.SettingsScreen -import com.sadellie.unitto.feature.settings.themes.ThemesRoute import com.sadellie.unitto.feature.settings.ThirdPartyLicensesScreen +import com.sadellie.unitto.feature.settings.formatting.FormattingRoute +import com.sadellie.unitto.feature.settings.themes.ThemesRoute import com.sadellie.unitto.feature.settings.unitgroups.UnitGroupsScreen import io.github.sadellie.themmo.ThemmoController @@ -38,6 +39,7 @@ internal const val themesRoute = "themes_route" internal const val unitsGroupRoute = "units_group_route" internal const val thirdPartyRoute = "third_party_route" internal const val aboutRoute = "about_route" +internal const val formattingRoute = "formatting_route" fun NavController.navigateToSettings(builder: NavOptionsBuilder.() -> Unit) { navigate(settingsRoute, builder) @@ -85,5 +87,11 @@ fun NavGraphBuilder.settingGraph( navigateUpAction = navController::navigateUp, ) } + + composable(formattingRoute) { + FormattingRoute( + navigateUpAction = navController::navigateUp + ) + } } }