Pith - stave
stave/app/src/main/kotlin/com/vgmlr/stave/StaveUI.kt [23.1 kb]
Modified: 23:41:07 158 026 (24 Aug 026)
0 Days Ago
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)
    )
}
Updates
Stave - Android 158.026
Kerf - Android 157.026
Kiln - Android 157.026
Wedge - Android 156.026
Whittle - Linux 155.026

Menu
Calendar
Project Tin (024/029)
Miter
RSS Feed
User Avatar
@vgmlr
=SUM(parts)
0.0025
257,429 (+39)