Pith - stave
package com.vgmlr.stave

import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectTapGestures
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.MyLocation
import androidx.compose.material.icons.outlined.AddCard
import androidx.compose.material.icons.outlined.Description
import androidx.compose.material.icons.outlined.Place
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.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
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.KeyboardCapitalization
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.IntOffset
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.atan2
import kotlin.math.cos
import kotlin.math.hypot
import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.math.sin
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

private val INPUT_ROW_H = 64.dp
private val PILL_RADIUS = 6.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 FIELD_ICON = 28.dp
private const val FIELD_ICON_ALPHA = 0.4f
internal val ROUND_SET = 6.dp
private val ROW_ICON = 44.dp
private val ROW_ICON_GAP = 14.dp

private val SELECT_BG = Color(0xFF868D97).copy(alpha = 0.1f)
private val CARD_BG = Color(0xFF272A30).copy(alpha = 0.2f)
private val CARD_PAD = 12.dp
private val CARD_LOC = Color(0xFF66837C)

private val PIE_SIZE = 156.dp
private const val PIE_GAP_DEG = 0.0f
private const val PIE_DIM = 0.22f
private val PIE_ICON = 16.dp
private val PIE_ICON_GAP = 8.dp

private val LEGEND_ICON = 14.dp
private val LEGEND_GAP = 6.dp
private val LEGEND_ROW_GAP = 5.dp
private val LEGEND_PAD_H = 16.dp
private val LEGEND_FS = 12.sp

private fun pct(f: Double): String = String.format(Locale.US, "%.1f%%", f * 100)

@Composable
fun CategoryDonut(
    totals: List<Pair<String, Double>>,
    themeManager: StaveThemeManager,
    modifier: Modifier = Modifier,
    selected: String? = null,
    onSelect: (String) -> Unit = {}
) {
    if (totals.isEmpty()) return
    val fg = themeManager.textColor.value
    val grand = totals.sumOf { it.second }
    val iconRadius = PIE_SIZE / 2 + PIE_ICON_GAP + PIE_ICON / 2
    val iconRadiusPx = with(LocalDensity.current) { iconRadius.toPx() }
    
    val arcs = remember(totals) {
        val t = totals.sumOf { it.second }.coerceAtLeast(0.01)
        var a = -90f
        totals.map { (cat, amt) ->
            Triple(cat, a, (amt / t * 360.0).toFloat()).also { a += it.third }
        }
    }

    val legend = remember(totals) { totals.sortedByDescending { it.second } }
    val base = grand.coerceAtLeast(0.01)

    Column(
        modifier = modifier
            .fillMaxWidth()
            .background(themeManager.backgroundColor.value)
    ) {
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .padding(horizontal = LEGEND_PAD_H, vertical = 12.dp),
            contentAlignment = Alignment.Center
        ) {
            Box(
                modifier = Modifier.size(iconRadius * 2),
                contentAlignment = Alignment.Center
            ) {
                Canvas(
                    modifier = Modifier
                        .size(PIE_SIZE)
                        .pointerInput(arcs) {
                            detectTapGestures { p ->
                                val outer = min(size.width, size.height) / 2f
                                val dx = p.x - size.width / 2f
                                val dy = p.y - size.height / 2f
                                val r = hypot(dx, dy)
                                if (r > outer) return@detectTapGestures
                                val deg = Math.toDegrees(atan2(dy, dx).toDouble()).toFloat()
                                arcs.firstOrNull { (_, start, sweep) ->
                                    ((deg - start) % 360f + 360f) % 360f < sweep
                                }?.let { onSelect(it.first) }
                            }
                        }
                ) {
                    arcs.forEach { (cat, start, sweep) ->
                        val on = selected == null || selected == cat
                        drawArc(
                            color = StaveCategory.color(cat)
                                .copy(alpha = if (on) 1f else PIE_DIM),
                            startAngle = start + PIE_GAP_DEG / 2f,
                            sweepAngle = (sweep - PIE_GAP_DEG).coerceAtLeast(0.5f),
                            useCenter = true
                        )
                    }
                }

                arcs.forEach { (cat, start, sweep) ->
                    key(cat) {
                        val on = selected == null || selected == cat
                        val rad = Math.toRadians((start + sweep / 2f).toDouble())
                        Icon(
                            imageVector = StaveCategory.icon(cat),
                            contentDescription = cat,
                            tint = StaveCategory.color(cat)
                                .copy(alpha = if (on) 1f else PIE_DIM),
                            modifier = Modifier
                                .size(PIE_ICON)
                                .offset {
                                    IntOffset(
                                        (cos(rad) * iconRadiusPx).roundToInt(),
                                        (sin(rad) * iconRadiusPx).roundToInt()
                                    )
                                }
                                .clickable(
                                    interactionSource = remember { MutableInteractionSource() },
                                    indication = null
                                ) { onSelect(cat) }
                        )
                    }
                }
            }
            LegendColumn(
                legend = legend,
                selected = selected,
                fg = fg,
                trailingIcon = false,
                value = { pct(it / base) },
                modifier = Modifier.align(Alignment.CenterStart)
            )

            LegendColumn(
                legend = legend,
                selected = selected,
                fg = fg,
                trailingIcon = true,
                value = { money(it) },
                modifier = Modifier.align(Alignment.CenterEnd)
            )
        }
        HorizontalDivider(
            thickness = 1.dp,
            color = colorResource(id = R.color.text_color).copy(alpha = 0.1f)
        )
    }
}

@Composable
private fun LegendColumn(
    legend: List<Pair<String, Double>>,
    selected: String?,
    fg: Color,
    trailingIcon: Boolean,
    value: (Double) -> String,
    modifier: Modifier = Modifier
) {
    Column(
        modifier = modifier,
        horizontalAlignment = if (trailingIcon) Alignment.End else Alignment.Start,
        verticalArrangement = Arrangement.spacedBy(LEGEND_ROW_GAP)
    ) {
        legend.forEach { (cat, amt) ->
            val a = if (selected == null || selected == cat) 1f else PIE_DIM
            val icon = @Composable {
                Icon(
                    imageVector = StaveCategory.icon(cat),
                    contentDescription = null,
                    tint = StaveCategory.color(cat).copy(alpha = a),
                    modifier = Modifier.size(LEGEND_ICON)
                )
            }
            Row(
                horizontalArrangement = Arrangement.spacedBy(LEGEND_GAP),
                verticalAlignment = Alignment.CenterVertically
            ) {
                if (!trailingIcon) icon()
                Text(
                    text = value(amt),
                    color = fg.copy(alpha = a),
                    fontSize = LEGEND_FS,
                    fontFamily = FontFamily.Monospace,
                    textAlign = if (trailingIcon) TextAlign.End else TextAlign.Start,
                    maxLines = 1
                )
                if (trailingIcon) icon()
            }
        }
    }
}

@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
    )

    val cardShape = RoundedCornerShape(ROUND_SET)
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .clip(cardShape)
            .background(colorResource(id = R.color.input_bg_color))
            .border(EDGE, cardShape)
    ) {
        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))
                },
                leadingIcon = { FieldIcon(Icons.Outlined.AddCard) },
                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)
                    )
                },
                leadingIcon = { FieldIcon(Icons.Outlined.Description) },
                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(
                    capitalization = KeyboardCapitalization.Sentences,
                    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)
                    )
                },
                leadingIcon = { FieldIcon(Icons.Outlined.Place) },
                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(
                    capitalization = KeyboardCapitalization.Sentences,
                    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(ROUND_SET)
                    )
            ) {
                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)
                )
            }
        }
    }
}

@Composable
private fun FieldIcon(icon: ImageVector) {
    Icon(
        icon,
        contentDescription = null,
        tint = colorResource(id = R.color.input_icon_color).copy(alpha = FIELD_ICON_ALPHA),
        modifier = Modifier.size(FIELD_ICON)
    )
}

// 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 = CARD_LOC,
                        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 TripCard(
    trip: StaveTrip,
    themeManager: StaveThemeManager,
    modifier: Modifier = Modifier,
    selected: Boolean = false,
    onClick: () -> Unit,
    onLongClick: () -> Unit = {}
) {
    val size = themeManager.fontSize.floatValue
    val family = if (themeManager.isMonospace.value) FontFamily.Monospace else FontFamily.Default
    val shape = RoundedCornerShape(ROUND_SET)
    val empty = trip.startLocation.isBlank()
    val locStyle = TextStyle(
        fontSize = (size * 0.85f).sp,
        color = themeManager.accentColor.value,
        fontFamily = family
    )
    val timeStyle = TextStyle(
        fontSize = (size * 0.75f).sp,
        color = themeManager.textColor.value.copy(alpha = 0.45f),
        fontFamily = FontFamily.Monospace
    )

    Box(modifier.fillMaxWidth()) {
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .clip(shape)
                .background(CARD_BG)
                .background(if (selected) SELECT_BG else Color.Transparent)
                .border(EDGE, shape)
                .combinedClickable(onClick = onClick, onLongClick = onLongClick)
                .padding(CARD_PAD),
            verticalArrangement = Arrangement.spacedBy(themeManager.rowGapDp().dp)
        ) {
            CardText(
                trip.name.ifBlank { UNNAMED },
                TextStyle(
                    fontSize = size.sp,
                    color = themeManager.textColor.value,
                    fontFamily = family
                )
            )
            Row(verticalAlignment = Alignment.CenterVertically) {
                CardText(if (empty) "-" else trip.startLocation, locStyle)
            }
            CardText(trip.stavetime, timeStyle)
            Row(verticalAlignment = Alignment.CenterVertically) {
                CardText(if (empty) "-" else trip.lastLocation, locStyle)
            }
            CardText(trip.lastTime.ifBlank { "-" }, timeStyle)
            CardText(
                money(trip.total),
                TextStyle(
                    fontSize = size.sp,
                    color = themeManager.textColor.value,
                    fontFamily = FontFamily.Monospace
                )
            )
        }
    }
}

@Composable
private fun CardText(text: String, style: TextStyle) {
    Text(
        text = text,
        style = style,
        maxLines = 1,
        overflow = TextOverflow.Ellipsis,
        modifier = Modifier.fillMaxWidth()
    )
}