Pith - stave
stave/app/src/main/kotlin/com/vgmlr/stave/StaveMainActivity.kt [21.7 kb]
Modified: 23:41:10 158 026 (24 Aug 026)
0 Days Ago
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 })
            }
        }
    }
}
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.00195
257,427 (+37)