package com.vgmlr.kiln
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
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.compose.ui.graphics.compositeOver
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.ui.graphics.graphicsLayer
import kotlin.math.PI
import kotlin.math.sin
private val ROW_PAD_H = 18.dp
private val FACE_SIZE = 96.dp
private val FACE_GAP = 10.dp
private val GRID_PAD = 6.dp
private val GRID_GAP = 12.dp
private val CARD_SHAPE = RoundedCornerShape(ROUND_END)
private val CARD_PAD = 14.dp
private val CARD_BORDER = 1.dp
private val CARD_ELEV = 6.dp
private val HEART_SIZE = 18.dp
private val DEL_SIZE = 28.dp
private const val SHAKE_MS = 420
private const val SHAKE_DEG = 20f
private const val SHAKE_CYCLES = 3f
private val STAT_GAP = 3.dp
private val LINE_GAP = 3.dp
private const val EMPTY_TEXT =
"\"..ayh don't trust any thing\nthat bleeds for five days\nand doesn't die.\""
@Composable
fun HomeScreen(
summaries: List<AccountSummary>,
onOpen: (KilnAccount) -> Unit,
onAdd: () -> Unit,
onDelete: (KilnAccount) -> Unit,
onSettings: () -> Unit,
onImport: (Uri, (Boolean) -> Unit) -> Unit
) {
var showVersion by remember { mutableStateOf(false) }
var importFailed by remember { mutableStateOf(false) }
var armed by remember { mutableStateOf<Long?>(null) }
val uriHandler = LocalUriHandler.current
BackHandler(enabled = armed != null) { armed = null }
val importer = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri -> uri?.let { u -> onImport(u) { ok -> importFailed = !ok } } }
Scaffold(
topBar = {
KilnTopBar(
title = "kiln",
onSettings = onSettings,
onVersion = { showVersion = true },
onImport = { importer.launch(arrayOf("*/*")) },
showExit = true,
action = {
KilnBarButton(Icons.Default.Add, "New Account", onClick = onAdd)
}
)
}
) { pad ->
Box(Modifier.fillMaxSize().padding(top = pad.calculateTopPadding())) {
if (summaries.isEmpty()) {
Text(
EMPTY_TEXT,
Modifier.align(Alignment.Center).padding(horizontal = ROW_PAD_H),
fontSize = 14.sp,
lineHeight = 22.sp,
textAlign = TextAlign.Center,
color = Color.White.copy(alpha = 0.4f)
)
} else {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = BAR_INSET_H,
end = BAR_INSET_H,
top = GRID_PAD,
bottom = GRID_PAD + pad.calculateBottomPadding()
),
horizontalArrangement = Arrangement.spacedBy(GRID_GAP),
verticalArrangement = Arrangement.spacedBy(GRID_GAP)
) {
items(summaries, key = { it.kilnAccount.id }) { s ->
val id = s.kilnAccount.id
AccountCard(
s,
armed = armed == id,
onClick = { if (armed != null) armed = null else onOpen(s.kilnAccount) },
onLongPress = { armed = id },
onDelete = { armed = null; onDelete(s.kilnAccount) }
)
}
}
}
}
}
VersionDialog(showVersion, onDismiss = { showVersion = false }, uriHandler = uriHandler)
if (importFailed) AlertDialog(
onDismissRequest = { importFailed = false },
modifier = Modifier.border(EDGE, RoundedCornerShape(ROUND_END)),
shape = RoundedCornerShape(ROUND_END),
containerColor = CCard,
confirmButton = {
Button(
onClick = { importFailed = false },
modifier = Modifier.height(44.dp),
colors = ButtonDefaults.buttonColors(
containerColor = CChip, contentColor = CText
),
shape = RoundedCornerShape(ROUND_END)
) { Text("Fine", color = CText, fontSize = 15.sp) }
},
text = { Text("Not a kiln_[timestamp].json file.", fontSize = 17.sp) }
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun AccountCard(
summary: AccountSummary,
armed: Boolean,
onClick: () -> Unit,
onLongPress: () -> Unit,
onDelete: () -> Unit
) {
val accent = KilnGradientPalette.colorFor(summary.todayHue)
val bucket = KilnGradientPalette.bucket(summary.todayHue)
val fill = CCard.compositeOver(MaterialTheme.colorScheme.background)
Column(
Modifier.fillMaxWidth()
.shadow(CARD_ELEV, CARD_SHAPE, ambientColor = accent, spotColor = accent)
.background(fill, CARD_SHAPE)
.border(CARD_BORDER, accent, CARD_SHAPE)
.combinedClickable(onClick = onClick, onLongClick = onLongPress)
.padding(CARD_PAD)
) {
Box(Modifier.fillMaxWidth()) {
Image(
painterResource(
KilnFaces.forHue(KilnHair.of(summary.kilnAccount.hair), summary.todayHue)
),
null,
Modifier.size(FACE_SIZE).align(Alignment.Center)
)
if (armed) DeleteBadge(Modifier.align(Alignment.TopEnd), onDelete)
}
Spacer(Modifier.height(FACE_GAP))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
summary.kilnAccount.name,
Modifier.weight(1f),
fontSize = 16.sp,
lineHeight = 16.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 0.2.sp,
color = Color.White.copy(alpha = 0.92f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (summary.isDefault) {
Spacer(Modifier.width(6.dp))
Icon(
Icons.Filled.Favorite,
"Default Account",
Modifier.size(HEART_SIZE),
tint = colorResource(ICON_BG[bucket])
)
}
}
Spacer(Modifier.height(STAT_GAP))
StatLine("Next PMS:", preLabel(summary.daysToPreMenstrual))
Spacer(Modifier.height(LINE_GAP))
StatLine("Next Period:", dueLabel(summary.daysToPeriod))
}
}
@Composable
private fun DeleteBadge(modifier: Modifier, onClick: () -> Unit) {
val shake = remember { Animatable(0f) }
LaunchedEffect(Unit) { shake.animateTo(1f, tween(SHAKE_MS, easing = LinearEasing)) }
Icon(
Icons.Outlined.Delete,
"Delete Account",
modifier
.size(DEL_SIZE)
.graphicsLayer {
val p = shake.value
rotationZ = SHAKE_DEG * sin(p * SHAKE_CYCLES * 2f * PI.toFloat()) * (1f - p)
}
.clickable(onClick = onClick),
tint = Color.White.copy(alpha = 0.85f)
)
}
@Composable
private fun StatLine(label: String, value: String) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
label,
fontSize = 12.sp,
lineHeight = 12.sp,
color = Color.White.copy(alpha = 0.50f)
)
Text(
value,
fontSize = 12.sp,
lineHeight = 12.sp,
fontWeight = FontWeight.Medium,
color = Color.White.copy(alpha = 0.85f),
maxLines = 1
)
}
}
private fun daysLabel(days: Long?): String = when (days) {
null -> "—"
0L -> "Today"
1L -> "1 Day"
else -> "$days Days"
}
private fun nowLabel(days: Long?): String = if (days == 0L) "Now" else daysLabel(days)
private fun preLabel(days: Long?): String = when {
days == null -> "—"
days == 0L -> "Now"
days < 0L -> "Now (+${-days})"
else -> daysLabel(days)
}
private fun dueLabel(days: Long?): String = when {
days == null -> "—"
days < 0L -> daysLabel(-days) + " Late"
else -> daysLabel(days)
}
private val ICON_BG = intArrayOf(
R.color.icon_bg_g0, R.color.icon_bg_g1, R.color.icon_bg_g2, R.color.icon_bg_g3,
R.color.icon_bg_g4, R.color.icon_bg_g5, R.color.icon_bg_g6, R.color.icon_bg_g7
)