package com.vgmlr.grain
import android.graphics.Color as AColor
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.animation.core.animateIntAsState
import androidx.compose.animation.core.tween
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.ui.graphics.Color
private val HISTORY_GAP = 18.dp
private val HISTORY_ROW_PAD = 6.dp
private val CONVERT_GAP = 6.dp
private val UNIT_GAP = 12.dp
private val UNIT_SLACK = 1.dp // rounding
private const val SLIDE_MS = 240
private const val MASK_MS = 220
private const val MASK_ALPHA = 0.7f
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.dark(AColor.TRANSPARENT),
navigationBarStyle = SystemBarStyle.dark(AColor.TRANSPARENT)
)
window.isNavigationBarContrastEnforced = false
setContent { GrainTheme { GrainApp() } }
}
}
private enum class Page { CALC, SETTINGS }
@Composable
fun GrainApp(vm: GrainViewModel = viewModel()) {
var page by rememberSaveable { mutableStateOf(Page.CALC) }
when (page) {
Page.CALC -> CalcScreen(vm) { page = Page.SETTINGS }
Page.SETTINGS -> {
BackHandler { page = Page.CALC }
SettingsView(vm) { page = Page.CALC }
}
}
}
@Composable
fun CalcScreen(vm: GrainViewModel, onSettings: () -> Unit) {
val bias by vm.bias.collectAsState()
val edgePad by vm.edgePad.collectAsState()
val keySize by vm.keySize.collectAsState()
val opsSide by vm.opsSide.collectAsState()
val digits by vm.digits.collectAsState()
val history by vm.history.collectAsState()
val font = digitsFont(digits)
val cat = vm.category
val density = LocalDensity.current
val uriHandler = LocalUriHandler.current
var group by remember { mutableStateOf(StackGroup.NONE) }
var showVersion by remember { mutableStateOf(false) }
var dockH by remember { mutableStateOf(0.dp) }
val topPad = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + TOP_BAR_H
var listOpen by remember { mutableStateOf(false) }
var listA by remember { mutableStateOf(true) } // side A
var panelW by remember { mutableIntStateOf(0) }
val shift by animateIntAsState(if (listOpen) panelW else 0, tween(SLIDE_MS), label = "slide")
val mask by animateFloatAsState(
if (group == StackGroup.CONVERT) MASK_ALPHA else 0f,
tween(MASK_MS), label = "mask"
)
val openList: (Boolean) -> Unit = { a -> listA = a; listOpen = true; group = StackGroup.NONE }
LaunchedEffect(cat) { listOpen = false }
BackHandler(group != StackGroup.NONE) { group = StackGroup.NONE }
BackHandler(listOpen) { listOpen = false }
Box(
Modifier
.fillMaxSize()
.background(PAGE_BG)
) {
Box(
Modifier
.fillMaxSize()
.offset { IntOffset(-shift, 0) }
.pointerInput(Unit) {
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
group = StackGroup.NONE
}
}
) {
HistoryList(history, font, topPad, dockH + HISTORY_GAP, Modifier.fillMaxSize())
// transparent dock
Column(
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.onSizeChanged { dockH = with(density) { it.height.toDp() } }
.navigationBarsPadding()
) {
if (cat == null) CalcField(vm.display, font)
else ConvertRow(vm, cat, font, openList)
Spacer(Modifier.height(FIELD_GAP))
Keypad(
bias,
edgePad.dp,
if (bias == Bias.FULL || keySize == KEY_AUTO) Dp.Unspecified else keySize.dp,
opsSide,
GrainMemory.value != null,
vm::press,
vm::clearAll,
Modifier.onSizeChanged {
vm.keypadW = with(density) { it.width.toDp().value } - BAR_EDGE.value * 2
}
)
Spacer(Modifier.height(KEYPAD_BOTTOM))
}
}
// convert mask
if (mask > 0f) {
Box(
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = mask))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
enabled = group == StackGroup.CONVERT
) { group = StackGroup.NONE }
)
}
CalcTopBar(
title = cat?.title ?: vm.memText ?: TITLE_IDLE_TEXT,
live = cat != null,
group = group,
onGroupChange = { group = it },
onPick = vm::openCategory,
onEscape = vm::closeCategory,
onCopy = vm::copy,
onPaste = vm::paste,
onSettings = onSettings,
onVersion = { showVersion = true },
onTitle = if (cat == null && GrainMemory.value != null) vm::recall else null,
modifier = Modifier
.align(Alignment.TopCenter)
.offset { IntOffset(-shift, 0) }
)
// dismiss list
if (listOpen) {
Box(
Modifier
.fillMaxSize()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { listOpen = false }
)
}
if (cat != null) {
UnitPanel(
units = cat.units,
index = if (listA) vm.unitA else vm.unitB,
onPick = { vm.pickUnit(listA, it); listOpen = false },
modifier = Modifier
.align(Alignment.TopEnd)
.offset { IntOffset(panelW - shift, 0) }
.onSizeChanged { panelW = it.width }
)
}
}
VersionDialog(showVersion, { showVersion = false }, uriHandler)
}
@Composable
private fun HistoryList(
lines: List<String>,
font: FontFamily,
topPad: Dp,
bottomPad: Dp,
modifier: Modifier = Modifier
) {
LazyColumn(
modifier.fillMaxWidth(),
reverseLayout = true,
contentPadding = PaddingValues(
start = BAR_EDGE,
end = BAR_EDGE,
top = topPad,
bottom = bottomPad
)
) {
items(lines.asReversed()) { line ->
Text(
line,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = HISTORY_ROW_PAD),
color = PAGE_INK.copy(alpha = HISTORY_ALPHA),
fontSize = HISTORY_FS,
fontFamily = font,
textAlign = TextAlign.End
)
}
}
}
@Composable
private fun CalcField(text: String, font: FontFamily) {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = BAR_EDGE)
.horizontalScroll(rememberScrollState(), reverseScrolling = true),
horizontalArrangement = Arrangement.End
) {
Text(
text.ifEmpty { "0" },
color = PAGE_INK,
fontSize = FIELD_FS,
fontFamily = font,
maxLines = 1,
softWrap = false
)
}
}
@Composable
private fun ConvertRow(
vm: GrainViewModel,
cat: Category,
font: FontFamily,
onOpen: (Boolean) -> Unit
) {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
val abbrA = cat.units[vm.unitA].abbr
val abbrB = cat.units[vm.unitB].abbr
val unitCol = remember(abbrA, abbrB, measurer, density) {
val px = maxOf(
measurer.measure(abbrA, UNIT_STYLE).size.width,
measurer.measure(abbrB, UNIT_STYLE).size.width
)
with(density) { px.toDp() } + UNIT_ARROW + UNIT_SLACK
}
Column(
Modifier
.fillMaxWidth()
.padding(horizontal = BAR_EDGE),
verticalArrangement = Arrangement.spacedBy(CONVERT_GAP)
) {
ConvertHalf(
value = vm.valueA,
focused = vm.focusA,
abbr = abbrA,
font = font,
unitCol = unitCol,
modifier = Modifier.fillMaxWidth(),
onFocus = { vm.focus(true) },
onOpen = { onOpen(true) }
)
ConvertHalf(
value = vm.valueB,
focused = !vm.focusA,
abbr = abbrB,
font = font,
unitCol = unitCol,
modifier = Modifier.fillMaxWidth(),
onFocus = { vm.focus(false) },
onOpen = { onOpen(false) }
)
}
}
@Composable
private fun ConvertHalf(
value: String,
focused: Boolean,
abbr: String,
font: FontFamily,
unitCol: Dp,
modifier: Modifier,
onFocus: () -> Unit,
onOpen: () -> Unit
) {
val alpha = if (focused) 1f else UNFOCUS_ALPHA
Row(modifier, verticalAlignment = Alignment.CenterVertically) {
Row(
Modifier
.weight(1f)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onFocus
)
.horizontalScroll(rememberScrollState(), reverseScrolling = true),
horizontalArrangement = Arrangement.End
) {
Text(
value.ifEmpty { "0" },
color = PAGE_INK.copy(alpha = alpha),
fontSize = FIELD_FS,
fontFamily = font,
maxLines = 1,
softWrap = false
)
}
Spacer(Modifier.width(UNIT_GAP))
UnitPicker(abbr, onOpen, Modifier.width(unitCol))
}
}