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.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
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.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ExitToApp
import androidx.compose.material.icons.automirrored.outlined.Article
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.BarChart
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.History
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.colorResource
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
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))
ensureTrip()
setContent {
StaveTheme(themeManager) {
MainScreen()
}
}
}
override fun onResume() {
super.onResume()
themeManager.refresh()
ensureTrip()
}
private fun ensureTrip() {
var trip = db.getTrip(StaveSession.currentTripId(this))
if (trip == null) trip = db.getAllTrips().firstOrNull()
trip?.let { StaveSession.setCurrentTripId(this, it.id) }
tripState.value = trip
entriesState.value = trip?.let { db.getEntries(it.id) } ?: emptyList()
}
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 selectedIds by remember { mutableStateOf<Set<Int>>(emptySet()) }
var showGraph by remember { mutableStateOf(false) }
var showNewTrip by remember { mutableStateOf(false) }
var showVersion by remember { mutableStateOf(false) }
var filterCat by remember { mutableStateOf<String?>(null) }
var inputActive by remember { mutableStateOf(true) }
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 graphTotal = remember(categoryTotals, filterCat) {
categoryTotals.firstOrNull { it.first == filterCat }?.second
?: categoryTotals.sumOf { 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
}
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()
}
}
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
selectedIds = emptySet()
inputActive = true
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
val txtExport = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("text/plain")
) { uri -> uri?.let { trip?.let { t -> db.exportTxt(this, it, t, entries) } } }
Box(Modifier.fillMaxSize()) {
Scaffold(
topBar = {
TopAppBar(
title = {
Box(
Modifier.fillMaxWidth().padding(end = 14.dp),
contentAlignment = Alignment.CenterEnd
) {
Text(
text = trip?.name ?: "stave",
fontSize = 18.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = colorResource(id = R.color.title_color)
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = colorResource(id = R.color.primary_color)
),
actions = {
if (selectedIds.isEmpty()) {
IconButton(onClick = { inputActive = true }) {
Icon(
Icons.AutoMirrored.Outlined.Article,
contentDescription = "New Entry",
tint = colorResource(id = R.color.title_color)
)
}
IconButton(onClick = {
showGraph = !showGraph
if (!showGraph) filterCat = null
}) {
Icon(
Icons.Default.BarChart,
contentDescription = "Graph",
tint = colorResource(id = R.color.title_color)
)
}
IconButton(
onClick = {
trip?.let { shareStave(this@StaveMainActivity, it, entries) }
},
enabled = trip != null && entries.isNotEmpty()
) {
Icon(
Icons.Default.Share,
contentDescription = "Share",
tint = colorResource(id = R.color.title_color)
)
}
} else {
IconButton(onClick = {
selectedIds.forEach { db.deleteEntry(it) }
selectedIds = emptySet()
refresh()
}) {
Icon(
Icons.Outlined.Delete,
contentDescription = "Delete",
tint = colorResource(id = R.color.title_color)
)
}
}
Box {
var expanded by remember { mutableStateOf(false) }
IconButton(onClick = { expanded = true }) {
Icon(
Icons.Default.MoreVert,
contentDescription = "More",
tint = colorResource(id = R.color.title_color)
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
modifier = Modifier
.background(colorResource(id = R.color.menu_bg_color))
.padding(horizontal = 6.dp)
) {
DropdownMenuItem(
text = {
Text(
"New Stave",
color = colorResource(id = R.color.menu_color)
)
},
onClick = { showNewTrip = true; expanded = false },
leadingIcon = {
Icon(
Icons.Default.Add, null,
tint = colorResource(id = R.color.menu_color)
)
}
)
DropdownMenuItem(
text = {
Text(
"Staves",
color = colorResource(id = R.color.menu_color)
)
},
onClick = {
startActivity(
Intent(
this@StaveMainActivity,
StaveTrips::class.java
)
)
expanded = false
},
leadingIcon = {
Icon(
Icons.Default.Search, null,
tint = colorResource(id = R.color.menu_color)
)
}
)
DropdownMenuItem(
text = {
Text(
"Export",
color = colorResource(id = R.color.menu_color)
)
},
enabled = trip != null,
onClick = {
trip?.let { txtExport.launch(StaveText.fileName(it)) }
expanded = false
},
leadingIcon = {
Icon(
Icons.AutoMirrored.Outlined.ExitToApp, null,
tint = colorResource(id = R.color.menu_color)
)
}
)
DropdownMenuItem(
text = {
Text(
"Settings",
color = colorResource(id = R.color.menu_color)
)
},
onClick = {
startActivity(
Intent(
this@StaveMainActivity,
StaveSettings::class.java
)
)
expanded = false
},
leadingIcon = {
Icon(
Icons.Outlined.Settings, null,
tint = colorResource(id = R.color.menu_color)
)
}
)
DropdownMenuItem(
text = {
Text(
"Version",
color = colorResource(id = R.color.menu_color)
)
},
onClick = { showVersion = true; expanded = false },
leadingIcon = {
Icon(
Icons.Outlined.History, null,
tint = colorResource(id = R.color.menu_color)
)
}
)
}
}
}
)
},
contentWindowInsets = WindowInsets(0, 0, 0, 0),
containerColor = themeManager.backgroundColor.value
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.background(themeManager.backgroundColor.value)
) {
AnimatedVisibility(
visible = showGraph && categoryTotals.isNotEmpty(),
enter = expandVertically(tween(220), Alignment.Top) + fadeIn(tween(220)),
exit = shrinkVertically(tween(200), Alignment.Top) + fadeOut(tween(140))
) {
Column {
GraphHeader(
total = graphTotal,
onClear = {
if (filterCat != null) filterCat = null
else showGraph = false
}
)
CategoryHistogram(
totals = categoryTotals,
themeManager = themeManager,
selected = filterCat,
onSelect = { cat ->
filterCat = if (filterCat == cat) null else cat
selectedIds = emptySet()
}
)
}
}
LazyColumn(Modifier.fillMaxSize()) {
items(visible, key = { it.id }) { entry ->
EntryRow(
entry = entry,
runningTotal = totals[entry.id] ?: 0.0,
themeManager = themeManager,
isSelected = entry.id in selectedIds,
isSelectionMode = selectedIds.isNotEmpty(),
onClick = {
if (selectedIds.isNotEmpty()) {
selectedIds =
if (entry.id in selectedIds) selectedIds - entry.id
else selectedIds + entry.id
}
},
onLongClick = {
selectedIds =
if (entry.id in selectedIds) selectedIds - entry.id
else selectedIds + entry.id
}
)
}
}
}
}
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
)
}
}
if (showNewTrip || trip == null) {
NewTripDialog(
dismissible = trip != null,
onDismiss = { showNewTrip = false },
onCreate = { name ->
val id = db.addTrip(name, StaveOTCClock.getCurrentOTCTime()).toInt()
StaveSession.setCurrentTripId(this@StaveMainActivity, id)
tripState.value = db.getTrip(id)
entriesState.value = emptyList()
}
)
}
if (showVersion) {
VersionDialog(onDismiss = { showVersion = false })
}
}
}
}