package com.vgmlr.stave
import android.content.Intent
import android.graphics.Color as AndroidColor
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ListAlt
import androidx.compose.material.icons.filled.Apps
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.outlined.FrontHand
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Place
import androidx.compose.material.icons.outlined.Receipt
import androidx.compose.material.icons.outlined.Save
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.activity.compose.BackHandler
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
class StaveMainActivity : ComponentActivity() {
private lateinit var db: StaveDatabase
private lateinit var themeManager: StaveThemeManager
private var tripState = mutableStateOf<StaveTrip?>(null)
private var entriesState = mutableStateOf<List<StaveEntry>>(emptyList())
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.setBackgroundDrawableResource(R.color.background_color)
db = StaveDatabase(this)
themeManager = StaveThemeManager(this)
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.dark(AndroidColor.TRANSPARENT),
navigationBarStyle = SystemBarStyle.dark(AndroidColor.TRANSPARENT)
)
window.isNavigationBarContrastEnforced = false
if (!ensureTrip()) return
setContent {
StaveTheme(themeManager) {
MainScreen()
}
}
}
override fun onResume() {
super.onResume()
themeManager.refresh()
ensureTrip()
}
private fun ensureTrip(): Boolean {
val trip = db.getTrip(StaveSession.currentTripId(this))
if (trip == null) {
StaveSession.clear(this)
startActivity(Intent(this, StaveTrips::class.java))
finish()
return false
}
tripState.value = trip
entriesState.value = db.getEntries(trip.id)
return true
}
private fun releaseTrip() {
StaveSession.clear(this)
ensureTrip()
}
private fun refresh() {
val id = tripState.value?.id ?: return
tripState.value = db.getTrip(id)
entriesState.value = db.getEntries(id)
}
private fun runningTotals(entries: List<StaveEntry>): Map<Int, Double> {
var sum = 0.0
val map = HashMap<Int, Double>(entries.size)
entries.asReversed().forEach { sum += it.cost; map[it.id] = sum }
return map
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen() {
val lifecycleOwner = LocalLifecycleOwner.current
val trip = tripState.value
val entries = entriesState.value
var marked by remember { mutableStateOf<Int?>(null) }
var filterCat by remember { mutableStateOf<String?>(null) }
var inputActive by remember { mutableStateOf(true) }
val listState = rememberLazyListState()
val haptic = LocalHapticFeedback.current
var addTick by remember { mutableIntStateOf(0) }
var cost by remember { mutableStateOf("") }
var category by remember { mutableStateOf(StaveCategory.DEFAULT) }
var note by remember { mutableStateOf("") }
var location by remember { mutableStateOf("") }
var lat by remember { mutableStateOf<Double?>(null) }
var lng by remember { mutableStateOf<Double?>(null) }
val totals = remember(entries) { runningTotals(entries) }
val categoryTotals = remember(entries) {
entries.groupBy { StaveCategory.normalize(it.category) }
.map { (cat, list) -> cat to list.sumOf { it.cost } }
.sortedByDescending { it.second }
}
val newestFirst = themeManager.isNewestFirst.value
val visible = remember(entries, filterCat, newestFirst) {
val list = filterCat
?.let { c -> entries.filter { StaveCategory.normalize(it.category) == c } }
?: entries
if (newestFirst) list else list.reversed()
}
LaunchedEffect(categoryTotals, filterCat) {
if (filterCat != null && categoryTotals.none { it.first == filterCat }) filterCat = null
}
LaunchedEffect(addTick) {
if (addTick == 0) return@LaunchedEffect
val i = if (newestFirst) 0 else visible.lastIndex
if (visible.isNotEmpty()) listState.animateScrollToItem(i)
}
BackHandler(enabled = marked != null) { marked = null }
val saveEntry: () -> Unit = {
val value = cost.toDoubleOrNull()
val id = trip?.id
if (value != null && value > 0.0 && id != null &&
category.isNotBlank() && location.isNotBlank()
) {
db.addEntry(
id, value, category, location.trim(),
lat, lng, StaveOTCClock.getCurrentOTCTime(), note.trim()
)
cost = ""
note = ""
location = ""
lat = null
lng = null
refresh()
addTick++
}
}
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
marked = null
inputActive = true
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
val txtExport = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("text/tab-separated-values")
) { uri -> uri?.let { trip?.let { t -> db.exportFile(this, it, t, entries) } } }
Box(Modifier.fillMaxSize()) {
Scaffold(
topBar = {
TopAppBar(
title = { },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
),
actions = {
Spacer(Modifier.width(BAR_EDGE))
StaveBlankButton(
Modifier.weight(1f),
label = trip?.name.orEmpty(),
placeholder = UNNAMED,
onRename = { name ->
trip?.let { db.renameTrip(it.id, name); refresh() }
}
)
Spacer(Modifier.width(BAR_GAP))
StaveButton(
Icons.Outlined.Place,
"Place",
enabled = entries.isNotEmpty()
) { trip?.let { StaveMap.open(this@StaveMainActivity, it.id) } }
Spacer(Modifier.width(BAR_GAP))
StaveButton(
Icons.Outlined.FrontHand,
"Release",
iconSize = BTN_ICON - 2.dp
) { releaseTrip() }
Spacer(Modifier.width(BAR_GAP))
Box {
var expanded by remember { mutableStateOf(false) }
StaveButton(Icons.Outlined.Receipt, "Menu") { expanded = true }
StaveMenu(expanded = expanded, onDismiss = { expanded = false }) {
StaveMenuItem("Staves", Icons.Default.Apps) {
startActivity(
Intent(this@StaveMainActivity, StaveTrips::class.java)
)
expanded = false
}
StaveMenuItem(
"Entry",
Icons.AutoMirrored.Outlined.ListAlt,
iconSize = BTN_ICON + 2.dp
) {
marked = null
inputActive = true
expanded = false
}
StaveMenuItem(
"Share",
Icons.Default.Share,
enabled = entries.isNotEmpty()
) {
trip?.let { shareStave(this@StaveMainActivity, it, entries) }
expanded = false
}
StaveMenuItem(
"Export",
Icons.Outlined.Save,
enabled = trip != null,
iconSize = BTN_ICON + 2.dp
) {
trip?.let { txtExport.launch(StaveText.fileName(it)) }
expanded = false
}
}
}
Spacer(Modifier.width(BAR_EDGE_ACTIONS))
}
)
},
contentWindowInsets = WindowInsets(0, 0, 0, 0),
containerColor = themeManager.backgroundColor.value
) { p ->
val topInset = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
val navInset =
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val density = LocalDensity.current
val headerTop = topInset + TOP_BAR_H
var graphH by remember { mutableStateOf(0.dp) }
Box(Modifier.fillMaxSize()) {
LazyColumn(
state = listState,
contentPadding = PaddingValues(
top = 0.dp,
bottom = p.calculateBottomPadding() + navInset + BTN_SIZE + DOCK_PAD * 2
),
modifier = Modifier
.fillMaxSize()
.padding(top = headerTop + graphH)
.clipToBounds()
.background(themeManager.backgroundColor.value)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { marked = null }
) {
items(visible, key = { it.id }) { entry ->
EntryRow(
entry = entry,
runningTotal = totals[entry.id] ?: 0.0,
themeManager = themeManager,
isSelected = marked == entry.id,
onClick = { marked = null },
onLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
marked = entry.id
}
)
}
}
Box(
Modifier
.align(Alignment.TopCenter)
.padding(top = headerTop)
.onGloballyPositioned { c ->
graphH = with(density) { c.size.height.toDp() }
}
) {
CategoryDonut(
totals = categoryTotals,
themeManager = themeManager,
selected = filterCat,
onSelect = { cat ->
filterCat = if (filterCat == cat) null else cat
marked = null
}
)
}
}
}
if (marked != null && !inputActive) {
StaveDock {
StaveMenuItem("Place", Icons.Outlined.Place) {
trip?.let { StaveMap.open(this@StaveMainActivity, it.id, marked) }
}
StaveMenuItem(
"Delete",
Icons.Outlined.Delete,
iconSize = BTN_ICON + 2.dp
) {
marked?.let { db.deleteEntry(it) }
marked = null
refresh()
}
}
}
if (inputActive) {
Box(
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.8f))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { inputActive = false }
)
Box(
Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.imePadding()
) {
InputBar(
cost = cost,
category = category,
note = note,
location = location,
themeManager = themeManager,
onCostChange = { cost = it },
onCategoryChange = { category = it },
onNoteChange = { note = it },
onLocationChange = { location = it; lat = null; lng = null },
onPinned = { fix ->
location = fix.city
lat = fix.lat
lng = fix.lng
},
onAdd = saveEntry
)
}
}
}
}
}