Refactor CalculatorScreen

This commit is contained in:
Sad Ellie 2023-09-27 14:27:56 +03:00
parent e14cd51070
commit c8b11a4b02
5 changed files with 225 additions and 197 deletions

View File

@ -19,12 +19,16 @@
package com.sadellie.unitto.core.ui.common
import android.view.HapticFeedbackConstants
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
@ -52,12 +56,18 @@ fun BasicKeyboardButton(
}
}
}
UnittoButton(
modifier = modifier,
onClick = { onClick(); vibrate() },
onLongClick = if (onLongClick != null) { { onLongClick(); vibrate() } } else null,
containerColor = containerColor,
contentPadding = PaddingValues()
Box(
modifier = modifier
.squashable(
onClick = { onClick(); vibrate() },
onLongClick = if (onLongClick != null) { { onLongClick(); vibrate() } } else null,
interactionSource = remember { MutableInteractionSource() },
cornerRadiusRange = 30..50,
)
.background(containerColor)
,
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,

View File

@ -20,10 +20,12 @@ package com.sadellie.unitto.feature.calculator
import android.content.res.Configuration
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.AnchoredDraggableState
import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.anchoredDraggable
import androidx.compose.foundation.gestures.snapTo
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
@ -49,7 +51,6 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@ -70,13 +71,13 @@ import com.sadellie.unitto.core.base.R
import com.sadellie.unitto.core.ui.common.MenuButton
import com.sadellie.unitto.core.ui.common.SettingsButton
import com.sadellie.unitto.core.ui.common.UnittoScreenWithTopBar
import com.sadellie.unitto.core.ui.common.textfield.ExpressionTextField
import com.sadellie.unitto.core.ui.common.textfield.UnformattedTextField
import com.sadellie.unitto.data.model.HistoryItem
import com.sadellie.unitto.feature.calculator.components.CalculatorKeyboard
import com.sadellie.unitto.feature.calculator.components.CalculatorKeyboardLoading
import com.sadellie.unitto.feature.calculator.components.HistoryItemHeight
import com.sadellie.unitto.feature.calculator.components.HistoryList
import kotlinx.coroutines.launch
import com.sadellie.unitto.feature.calculator.components.TextBox
import java.text.SimpleDateFormat
import java.util.Locale
@ -179,146 +180,89 @@ private fun Ready(
modifier = Modifier.padding(paddingValues),
) {
val density = LocalDensity.current
var historyItemHeight by remember { mutableStateOf(0.dp) }
val textBoxHeight = maxHeight * 0.25f
var dragStateCurrentValue by rememberSaveable { mutableStateOf(DragState.CLOSED) }
val corScope = rememberCoroutineScope()
val dragState = rememberDragState(
historyItem = historyItemHeight,
max = maxHeight - textBoxHeight,
initialValue = dragStateCurrentValue,
enablePartialView = uiState.partialHistoryView
)
val dragDp by remember(dragState) {
val textBoxHeight = maxHeight * 0.25f
val dragState = remember {
AnchoredDraggableState(
initialValue = DragState.CLOSED,
positionalThreshold = { distance -> distance * 0.5f },
velocityThreshold = { with(density) { HistoryItemHeight.toPx() } },
animationSpec = tween()
)
}
var historyListHeight by remember { mutableStateOf(0.dp) }
val keyboardHeight by remember(historyListHeight) {
derivedStateOf {
focusManager.clearFocus(true)
with(density) {
try {
dragState.requireOffset().toDp()
} catch (e: IllegalStateException) {
corScope.launch { dragState.snapTo(DragState.CLOSED) }
0.dp
if (historyListHeight > HistoryItemHeight) {
maxHeight - textBoxHeight - HistoryItemHeight
} else {
maxHeight - textBoxHeight - historyListHeight
}
}
}
LaunchedEffect(uiState.partialHistoryView) {
val anchors: DraggableAnchors<DragState> = with(density) {
if (uiState.partialHistoryView) {
DraggableAnchors {
DragState.CLOSED at 0f
DragState.SMALL at HistoryItemHeight.toPx()
DragState.OPEN at (maxHeight * 0.75f).toPx()
}
} else {
DraggableAnchors {
DragState.CLOSED at 0f
DragState.OPEN at (maxHeight * 0.75f).toPx()
}
}
}
}
val keyboardHeight by remember(dragState) {
derivedStateOf {
if (dragDp > historyItemHeight) {
maxHeight - textBoxHeight - historyItemHeight
} else {
maxHeight - textBoxHeight - dragDp
}
}
dragState.updateAnchors(anchors)
}
LaunchedEffect(dragState.currentValue) {
dragStateCurrentValue = dragState.currentValue
LaunchedEffect(dragState.offset) {
with(density) {
if (!dragState.offset.isNaN()) {
historyListHeight = dragState.requireOffset().toDp()
}
}
focusManager.clearFocus()
showClearHistoryButton = dragState.currentValue == DragState.OPEN
}
// History
HistoryList(
modifier = Modifier
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
.fillMaxWidth()
.height(dragDp),
.height(historyListHeight),
historyItems = uiState.history,
heightCallback = { historyItemHeight = it },
formatterSymbols = uiState.formatterSymbols,
addTokens = addSymbol,
addTokens = addSymbol
)
// Input
Column(
TextBox(
modifier = Modifier
.semantics { testTag = "inputBox" }
.offset(y = dragDp)
.offset(y = historyListHeight)
.height(textBoxHeight)
.background(
MaterialTheme.colorScheme.surfaceVariant,
RoundedCornerShape(
topStartPercent = 0, topEndPercent = 0,
bottomStartPercent = 20, bottomEndPercent = 20
)
)
.fillMaxWidth()
.anchoredDraggable(
state = dragState,
orientation = Orientation.Vertical
)
.padding(top = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
ExpressionTextField(
modifier = Modifier
.weight(2f)
.fillMaxWidth()
.padding(horizontal = 8.dp),
value = uiState.input,
minRatio = 0.5f,
cutCallback = deleteSymbol,
pasteCallback = addSymbol,
onCursorChange = onCursorChange,
formatterSymbols = uiState.formatterSymbols
)
if (LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT) {
when (uiState.output) {
is CalculationResult.Default -> {
var output by remember(uiState.output) {
mutableStateOf(TextFieldValue(uiState.output.text))
}
,
formatterSymbols = uiState.formatterSymbols,
input = uiState.input,
deleteSymbol = deleteSymbol,
addSymbol = addSymbol,
onCursorChange = onCursorChange,
output = uiState.output
)
ExpressionTextField(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 8.dp),
value = output,
minRatio = 1f,
onCursorChange = { output = output.copy(selection = it) },
formatterSymbols = uiState.formatterSymbols,
textColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(0.6f),
readOnly = true,
)
}
else -> {
val label = uiState.output.label?.let { stringResource(it) } ?: ""
UnformattedTextField(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 8.dp),
value = TextFieldValue(label),
minRatio = 1f,
onCursorChange = {},
textColor = MaterialTheme.colorScheme.error,
readOnly = true,
)
}
}
}
// Handle
Box(
Modifier
.padding(8.dp)
.background(
MaterialTheme.colorScheme.onSurfaceVariant,
RoundedCornerShape(100)
)
.sizeIn(24.dp, 4.dp)
)
}
// Keyboard
CalculatorKeyboard(
modifier = Modifier
.semantics { testTag = "ready" }
.offset(y = dragDp + textBoxHeight)
.offset(y = historyListHeight + textBoxHeight)
.height(keyboardHeight)
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),

View File

@ -18,45 +18,4 @@
package com.sadellie.unitto.feature.calculator
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.AnchoredDraggableState
import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
internal enum class DragState { CLOSED, SMALL, OPEN }
@Composable
internal fun rememberDragState(
initialValue: DragState = DragState.CLOSED,
historyItem: Dp,
max: Dp,
enablePartialView: Boolean,
): AnchoredDraggableState<DragState> {
val historyItemHeight = with(LocalDensity.current) { historyItem.toPx() }
val maxHeight = with(LocalDensity.current) { max.toPx() }
val anchors: DraggableAnchors<DragState> = if (enablePartialView) {
DraggableAnchors {
DragState.CLOSED at 0f
DragState.SMALL at historyItemHeight
DragState.OPEN at maxHeight
}
} else {
DraggableAnchors {
DragState.CLOSED at 0f
DragState.OPEN at maxHeight
}
}
return remember(historyItem, enablePartialView) {
AnchoredDraggableState(
initialValue = initialValue,
anchors = anchors,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
animationSpec = tween()
)
}
}

View File

@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn
@ -47,9 +48,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalTextInputService
import androidx.compose.ui.platform.LocalTextToolbar
import androidx.compose.ui.platform.LocalView
@ -58,7 +57,6 @@ import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.sadellie.unitto.core.base.R
import com.sadellie.unitto.core.ui.common.textfield.ExpressionTransformer
@ -75,14 +73,12 @@ import java.util.Locale
internal fun HistoryList(
modifier: Modifier,
historyItems: List<HistoryItem>,
heightCallback: (Dp) -> Unit,
formatterSymbols: FormatterSymbols,
addTokens: (String) -> Unit,
) {
if (historyItems.isEmpty()) {
HistoryListPlaceholder(
modifier = modifier,
heightCallback = heightCallback
)
} else {
HistoryListContent(
@ -90,7 +86,6 @@ internal fun HistoryList(
historyItems = historyItems,
addTokens = addTokens,
formatterSymbols = formatterSymbols,
heightCallback = heightCallback
)
}
}
@ -98,19 +93,14 @@ internal fun HistoryList(
@Composable
private fun HistoryListPlaceholder(
modifier: Modifier,
heightCallback: (Dp) -> Unit,
) {
val density = LocalDensity.current
Column(
modifier = modifier.wrapContentHeight(unbounded = true),
verticalArrangement = Arrangement.Center
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Column(
modifier = Modifier
.onPlaced { heightCallback(with(density) { it.size.height.toDp() }) }
.fillMaxWidth()
.padding(vertical = 32.dp),
modifier = Modifier.height(HistoryItemHeight),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
@ -126,12 +116,8 @@ private fun HistoryListContent(
historyItems: List<HistoryItem>,
addTokens: (String) -> Unit,
formatterSymbols: FormatterSymbols,
heightCallback: (Dp) -> Unit,
) {
val density = LocalDensity.current
val state = rememberLazyListState()
val firstItem by remember(historyItems) { mutableStateOf(historyItems.first()) }
val restOfTheItems by remember(firstItem) { mutableStateOf(historyItems.drop(1)) }
LaunchedEffect(historyItems) { state.scrollToItem(0) }
@ -141,19 +127,8 @@ private fun HistoryListContent(
reverseLayout = true,
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Bottom)
) {
// We do this so that callback for items height is called only once
item(firstItem.id) {
items(historyItems, { it.id }) { historyItem ->
HistoryListItem(
modifier = Modifier.onPlaced { heightCallback(with(density) { it.size.height.toDp() }) },
historyItem = historyItems.first(),
formatterSymbols = formatterSymbols,
addTokens = addTokens,
)
}
items(restOfTheItems, { it.id }) { historyItem ->
HistoryListItem(
modifier = Modifier,
historyItem = historyItem,
formatterSymbols = formatterSymbols,
addTokens = addTokens,
@ -193,7 +168,10 @@ private fun HistoryListItem(
}
}
Column(modifier = modifier) {
Column(
modifier = modifier.height(HistoryItemHeight),
verticalArrangement = Arrangement.Center
) {
CompositionLocalProvider(
LocalTextInputService provides null,
LocalTextToolbar provides UnittoTextToolbar(
@ -246,6 +224,8 @@ private fun HistoryListItem(
}
}
internal val HistoryItemHeight = 92.dp
@Preview
@Composable
private fun PreviewHistoryList() {
@ -275,7 +255,6 @@ private fun PreviewHistoryList() {
.fillMaxSize(),
historyItems = historyItems,
formatterSymbols = FormatterSymbols.Spaces,
heightCallback = {},
addTokens = {}
)
}

View File

@ -0,0 +1,136 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package com.sadellie.unitto.feature.calculator.components
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import com.sadellie.unitto.core.ui.common.textfield.ExpressionTextField
import com.sadellie.unitto.core.ui.common.textfield.FormatterSymbols
import com.sadellie.unitto.core.ui.common.textfield.UnformattedTextField
import com.sadellie.unitto.feature.calculator.CalculationResult
@Composable
fun TextBox(
modifier: Modifier,
formatterSymbols: FormatterSymbols,
input: TextFieldValue,
deleteSymbol: () -> Unit,
addSymbol: (String) -> Unit,
onCursorChange: (TextRange) -> Unit,
output: CalculationResult,
) {
Column(
modifier = modifier
.semantics { testTag = "inputBox" }
.background(
MaterialTheme.colorScheme.surfaceVariant,
RoundedCornerShape(
topStartPercent = 0, topEndPercent = 0,
bottomStartPercent = 20, bottomEndPercent = 20
)
)
.padding(top = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
ExpressionTextField(
modifier = Modifier
.weight(2f)
.fillMaxWidth()
.padding(horizontal = 8.dp),
value = input,
minRatio = 0.5f,
cutCallback = deleteSymbol,
pasteCallback = addSymbol,
onCursorChange = onCursorChange,
formatterSymbols = formatterSymbols
)
if (LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT) {
when (output) {
is CalculationResult.Default -> {
var outputTF by remember(output) {
mutableStateOf(TextFieldValue(output.text))
}
ExpressionTextField(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 8.dp),
value = outputTF,
minRatio = 1f,
onCursorChange = { outputTF = outputTF.copy(selection = it) },
formatterSymbols = formatterSymbols,
textColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(0.6f),
readOnly = true,
)
}
else -> {
val label = output.label?.let { stringResource(it) } ?: ""
UnformattedTextField(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 8.dp),
value = TextFieldValue(label),
minRatio = 1f,
onCursorChange = {},
textColor = MaterialTheme.colorScheme.error,
readOnly = true,
)
}
}
}
// Handle
Box(
Modifier
.padding(8.dp)
.background(
MaterialTheme.colorScheme.onSurfaceVariant,
RoundedCornerShape(100)
)
.sizeIn(24.dp, 4.dp)
)
}
}