package com.vgmlr.stave
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardReturn
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.MyLocation
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import java.util.Locale
import kotlin.math.roundToInt
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private val INPUT_ROW_H = 64.dp
private val PILL_RADIUS = 8.dp
private val PILL_HEIGHT = 36.dp
private val PILL_GAP = 8.dp
private val PILL_PAD_H = 16.dp
private val PILL_ROW_PAD_H = 12.dp
private val PILL_FS = 16.sp
private val PILL_FILL_ON = Color(0xFF4A515B)
private val PILL_FILL_OFF = Color(0xFF2E323A)
private const val PILL_IDLE_TEXT_ALPHA = 0.6f
private val PILL_ICON = 16.dp
private val PILL_ICON_GAP = 6.dp
private val ROW_ICON = 44.dp
private val ROW_ICON_GAP = 14.dp
private val SELECT_BG = Color(0xFF868D97).copy(alpha = 0.28f)
private val GRAPH_BAR_BG = Color(0xFF272A30)
private val GRAPH_BAR_H = 52.dp
private val BAR_MAX_H = 92.dp
private val BAR_GAP = 6.dp
private fun compact(v: Double): String = when {
v >= 10_000 -> "${(v / 1000).roundToInt()}k"
v >= 1_000 -> String.format(Locale.US, "%.1fk", v / 1000)
else -> String.format(Locale.US, "%.0f", v)
}
@Composable
fun GraphHeader(
total: Double,
onClear: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.height(GRAPH_BAR_H)
.background(GRAPH_BAR_BG)
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(38.dp)
.clip(RoundedCornerShape(10.dp))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClear
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Clear,
contentDescription = "Clear",
tint = Color.White,
modifier = Modifier.size(22.dp)
)
}
Text(
text = money(total),
color = Color.White,
fontSize = 16.sp,
fontFamily = FontFamily.Monospace,
textAlign = TextAlign.End,
maxLines = 1,
modifier = Modifier
.weight(1f)
.padding(end = 8.dp)
)
}
}
@Composable
fun CategoryHistogram(
totals: List<Pair<String, Double>>,
themeManager: StaveThemeManager,
modifier: Modifier = Modifier,
selected: String? = null,
onSelect: (String) -> Unit = {}
) {
if (totals.isEmpty()) return
val max = totals.maxOf { it.second }.coerceAtLeast(0.01)
val fg = themeManager.textColor.value
Column(
modifier = modifier
.fillMaxWidth()
.background(themeManager.backgroundColor.value)
) {
Column(Modifier.padding(horizontal = 12.dp, vertical = 14.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(BAR_GAP),
verticalAlignment = Alignment.Bottom
) {
totals.forEach { (cat, amt) ->
val on = selected == null || selected == cat
val bar = StaveCategory.color(cat).copy(alpha = if (on) 1f else 0.25f)
Column(
modifier = Modifier
.weight(1f)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { onSelect(cat) },
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = compact(amt),
color = fg.copy(alpha = if (on) 0.75f else 0.25f),
fontSize = 10.sp,
fontFamily = FontFamily.Monospace,
maxLines = 1
)
Spacer(Modifier.height(4.dp))
Box(
Modifier
.fillMaxWidth()
.height((BAR_MAX_H * (amt / max).toFloat()).coerceAtLeast(2.dp))
.background(
bar,
RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp)
)
)
Spacer(Modifier.height(6.dp))
Icon(
imageVector = StaveCategory.icon(cat),
contentDescription = cat,
tint = bar,
modifier = Modifier.size(16.dp)
)
}
}
}
}
HorizontalDivider(
thickness = 1.dp,
color = colorResource(id = R.color.text_color).copy(alpha = 0.1f)
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun InputBar(
cost: String,
category: String,
note: String,
location: String,
themeManager: StaveThemeManager,
onCostChange: (String) -> Unit,
onCategoryChange: (String) -> Unit,
onNoteChange: (String) -> Unit,
onLocationChange: (String) -> Unit,
onPinned: (StaveFix) -> Unit,
onAdd: () -> Unit
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val lifecycleOwner = LocalLifecycleOwner.current
val costFocus = remember { FocusRequester() }
val noteFocus = remember { FocusRequester() }
val locationFocus = remember { FocusRequester() }
var locating by remember { mutableStateOf(false) }
val canAdd = (cost.toDoubleOrNull() ?: 0.0) > 0.0 &&
category.isNotBlank() &&
location.isNotBlank()
val locate: () -> Unit = {
scope.launch {
locating = true
StaveLocator.fix(context)?.let(onPinned)
locating = false
}
}
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { granted -> if (granted.values.any { it }) locate() }
val submit: () -> Unit = {
if (canAdd) {
onAdd()
costFocus.requestFocus()
}
}
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) costFocus.requestFocus()
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
LaunchedEffect(Unit) {
delay(100)
costFocus.requestFocus()
}
val fieldColors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedTextColor = colorResource(id = R.color.input_color),
unfocusedTextColor = colorResource(id = R.color.input_color),
cursorColor = colorResource(id = R.color.input_color),
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
)
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp))
.background(colorResource(id = R.color.input_bg_color))
) {
Row(
modifier = Modifier.height(INPUT_ROW_H),
verticalAlignment = Alignment.CenterVertically
) {
TextField(
value = cost,
onValueChange = { raw -> onCostChange(sanitizeCost(raw)) },
placeholder = {
Text("Cost", color = colorResource(id = R.color.input_color).copy(alpha = 0.5f))
},
textStyle = TextStyle(
fontSize = 17.sp,
fontFamily = FontFamily.Monospace,
color = colorResource(id = R.color.input_color)
),
modifier = Modifier
.weight(1f)
.focusRequester(costFocus)
.padding(horizontal = 8.dp, vertical = 4.dp),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Decimal,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(onNext = { noteFocus.requestFocus() }),
colors = fieldColors,
singleLine = true
)
}
HorizontalDivider(thickness = 2.dp, color = colorResource(id = R.color.background_color))
CategoryPills(value = category, onSelect = onCategoryChange)
HorizontalDivider(thickness = 2.dp, color = colorResource(id = R.color.background_color))
Row(
modifier = Modifier.height(INPUT_ROW_H),
verticalAlignment = Alignment.CenterVertically
) {
TextField(
value = note,
onValueChange = onNoteChange,
placeholder = {
Text(
"Description",
color = colorResource(id = R.color.input_color).copy(alpha = 0.5f)
)
},
textStyle = TextStyle(
fontSize = 17.sp,
color = colorResource(id = R.color.input_color)
),
modifier = Modifier
.weight(1f)
.focusRequester(noteFocus)
.padding(horizontal = 8.dp, vertical = 4.dp),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { locationFocus.requestFocus() }),
colors = fieldColors,
singleLine = true
)
}
HorizontalDivider(thickness = 2.dp, color = colorResource(id = R.color.background_color))
Row(
modifier = Modifier.height(INPUT_ROW_H),
verticalAlignment = Alignment.CenterVertically
) {
TextField(
value = location,
onValueChange = onLocationChange,
placeholder = {
Text(
if (locating) "Locating..." else "Location",
color = colorResource(id = R.color.input_color).copy(alpha = 0.5f)
)
},
textStyle = TextStyle(
fontSize = 17.sp,
color = colorResource(id = R.color.input_color)
),
modifier = Modifier
.weight(1f)
.focusRequester(locationFocus)
.padding(horizontal = 8.dp, vertical = 4.dp),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { submit() }),
colors = fieldColors,
singleLine = true
)
IconButton(
onClick = submit,
enabled = canAdd,
modifier = Modifier
.background(
Color.White.copy(alpha = if (canAdd) 0.05f else 0.0f),
RoundedCornerShape(15.dp)
)
) {
Icon(
Icons.AutoMirrored.Filled.KeyboardReturn,
contentDescription = "Add",
tint = colorResource(id = R.color.input_icon_color)
.copy(alpha = if (canAdd) 0.5f else 0.0f),
modifier = Modifier.size(26.dp)
)
}
IconButton(
onClick = {
if (StaveLocator.hasPermission(context)) locate()
else permissionLauncher.launch(StaveLocator.PERMISSIONS)
},
modifier = Modifier.padding(start = 2.dp, end = 14.dp)
) {
Icon(
Icons.Default.MyLocation,
contentDescription = "Drop Pin",
tint = colorResource(id = R.color.input_icon_color).copy(alpha = 0.5f),
modifier = Modifier.size(22.dp)
)
}
}
HorizontalDivider(thickness = 2.dp, color = colorResource(id = R.color.background_color))
}
}
// digits, single dot, max 2 decimals
private fun sanitizeCost(raw: String): String {
val f = raw.replace(',', '.').filter { it.isDigit() || it == '.' }
if (f.count { it == '.' } > 1) return f.substringBeforeLast('.')
val decimals = f.substringAfter('.', "")
return if (decimals.length > 2) f.substringBefore('.') + "." + decimals.take(2) else f
}
@Composable
private fun CategoryPills(
value: String,
onSelect: (String) -> Unit,
modifier: Modifier = Modifier
) {
val shape = RoundedCornerShape(PILL_RADIUS)
val fg = colorResource(id = R.color.input_color)
Row(
modifier = modifier
.fillMaxWidth()
.height(INPUT_ROW_H)
.horizontalScroll(rememberScrollState())
.padding(horizontal = PILL_ROW_PAD_H),
horizontalArrangement = Arrangement.spacedBy(PILL_GAP),
verticalAlignment = Alignment.CenterVertically
) {
StaveCategory.ALL.forEach { opt ->
val selected = opt == value
Box(
modifier = Modifier
.height(PILL_HEIGHT)
.clip(shape)
.background(if (selected) PILL_FILL_ON else PILL_FILL_OFF)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { onSelect(opt) }
.padding(horizontal = PILL_PAD_H),
contentAlignment = Alignment.Center
) {
Row(
horizontalArrangement = Arrangement.spacedBy(PILL_ICON_GAP),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = StaveCategory.icon(opt),
contentDescription = null,
tint = if (selected) Color.White
else Color.White.copy(alpha = PILL_IDLE_TEXT_ALPHA),
modifier = Modifier.size(PILL_ICON)
)
Text(
text = opt,
color = if (selected) fg else fg.copy(alpha = PILL_IDLE_TEXT_ALPHA),
fontSize = PILL_FS,
maxLines = 1
)
}
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun EntryRow(
entry: StaveEntry,
runningTotal: Double,
themeManager: StaveThemeManager,
isSelected: Boolean = false,
isSelectionMode: Boolean = false,
onClick: () -> Unit = {},
onLongClick: () -> Unit = {}
) {
val size = themeManager.fontSize.floatValue
val family = if (themeManager.isMonospace.value) FontFamily.Monospace else FontFamily.Default
Row(
modifier = Modifier
.fillMaxWidth()
.background(if (isSelected) SELECT_BG else Color.Transparent)
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.Top
) {
Icon(
imageVector = StaveCategory.icon(entry.category),
contentDescription = null,
tint = StaveCategory.color(entry.category),
modifier = Modifier
.size(ROW_ICON)
.align(Alignment.CenterVertically)
)
Spacer(Modifier.width(ROW_ICON_GAP))
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(themeManager.rowGapDp().dp)
) {
Row(Modifier.fillMaxWidth()) {
Text(
text = entry.label,
style = TextStyle(
fontSize = size.sp,
lineHeight = themeManager.lineHeightSp().sp,
color = themeManager.textColor.value,
fontFamily = family
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f).alignByBaseline()
)
Text(
text = money(entry.cost),
style = TextStyle(
fontSize = size.sp,
color = themeManager.textColor.value,
fontFamily = FontFamily.Monospace,
textAlign = TextAlign.End
),
maxLines = 1,
modifier = Modifier.width(86.dp).alignByBaseline()
)
Text(
text = money(runningTotal),
style = TextStyle(
fontSize = (size * 0.9f).sp,
color = colorResource(id = R.color.total_color),
fontFamily = FontFamily.Monospace,
textAlign = TextAlign.End
),
maxLines = 1,
modifier = Modifier.width(96.dp).alignByBaseline()
)
}
if (entry.location.isNotBlank()) {
Text(
text = entry.location,
style = TextStyle(
fontSize = (size * 0.85f).sp,
lineHeight = themeManager.lineHeightSp().sp,
color = themeManager.accentColor.value,
fontFamily = family
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
)
}
Text(
text = entry.stavetime,
style = TextStyle(
fontSize = (size * 0.75f).sp,
lineHeight = themeManager.lineHeightSp().sp,
color = themeManager.textColor.value.copy(alpha = 0.45f),
fontFamily = FontFamily.Monospace
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
)
}
}
HorizontalDivider(
thickness = 1.dp,
color = colorResource(id = R.color.text_color).copy(alpha = 0.1f)
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TripRow(
trip: StaveTrip,
isSelected: Boolean = false,
themeManager: StaveThemeManager,
onClick: () -> Unit,
onLongClick: () -> Unit = {}
) {
val size = themeManager.fontSize.floatValue
val family = if (themeManager.isMonospace.value) FontFamily.Monospace else FontFamily.Default
val route = when {
trip.startLocation.isBlank() -> "no entries"
trip.startLocation == trip.lastLocation -> trip.startLocation
else -> "${trip.startLocation} -> ${trip.lastLocation}"
}
Row(
modifier = Modifier
.fillMaxWidth()
.background(if (isSelected) SELECT_BG else Color.Transparent)
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(themeManager.rowGapDp().dp)
) {
Text(
text = trip.name,
style = TextStyle(
fontSize = size.sp,
color = themeManager.textColor.value,
fontFamily = family
),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = route,
style = TextStyle(
fontSize = (size * 0.85f).sp,
color = themeManager.accentColor.value,
fontFamily = family
),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = trip.stavetime,
style = TextStyle(
fontSize = (size * 0.75f).sp,
color = themeManager.textColor.value.copy(alpha = 0.45f),
fontFamily = FontFamily.Monospace
),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Text(
text = money(trip.total),
style = TextStyle(
fontSize = size.sp,
color = themeManager.textColor.value,
fontFamily = FontFamily.Monospace,
textAlign = TextAlign.End
),
maxLines = 1,
modifier = Modifier.width(100.dp)
)
}
HorizontalDivider(
thickness = 1.dp,
color = colorResource(id = R.color.text_color).copy(alpha = 0.1f)
)
}