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

View File

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

View File

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