package com.vgmlr.varnish
import android.content.Context
import android.content.Intent
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.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
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.ArrowBack
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.outlined.Gradient
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.TextRange
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.TextFieldValue
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.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
internal val PageBg = Color(0xFF111111)
internal val PageText = Color(0xFFEFEFEF)
internal val BtnBg = Color(0xFF486860)
internal val BtnInk = Color(0xFFFFFFFF)
internal val FieldBg = Color(0xFF272A30)
internal val FieldInk = PageText
internal val SelectBorder = Color(0xFFAA0000)
internal val CCard = Color(0xFF272A30).copy(alpha = 0.8f)
internal val CCardDialog = Color(0xFF272A30)
internal val CHead = Color(0xFF33373E).copy(alpha = 0.8f)
internal val CText = Color(0xFFE8EAED)
internal val CMuted = Color(0xFF868D97)
internal val CChip = Color(0xFF3A4048)
internal val COutline = CMuted.copy(alpha = 0.30f)
internal val WarnBg = Color(0xFF880000)
internal val BTN_RADIUS = VarnishConfig.BTN_RADIUS.dp
internal val CHIP_RADIUS = 6.dp
private val BUBBLE_RADIUS = 4.dp
internal val BTN = 48.dp
internal val BTN_ICON = 28.dp
internal val DEL_ICON = BTN_ICON + 2.dp
internal val MENU_ICON = BTN_ICON + 2.dp
internal val BTN_PAD = 8.dp
internal val GAP = 8.dp
internal val EDGE = 12.dp
internal val TOP_BAR_H = BTN + GAP * 2
internal val DOCK_PAD = 16.dp
private val BAR_LABEL = 17.sp
private val BAR_LABEL_PAD = 14.dp
private const val LABEL_ALPHA = 0.6f
private const val ANIM_IN = 220
private const val FADE_IN = 180
private const val ANIM_OUT = 220
private const val FADE_OUT = 140
@Composable
internal fun VarnishTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = darkColorScheme(
primary = BtnBg,
onPrimary = BtnInk,
background = PageBg,
onBackground = PageText,
surface = PageBg,
onSurface = PageText,
surfaceVariant = FieldBg,
onSurfaceVariant = PageText
),
content = content
)
}
@Composable
internal fun VButton(
icon: ImageVector,
desc: String?,
modifier: Modifier = Modifier,
enabled: Boolean = true,
iconSize: Dp = BTN_ICON,
iconPad: Dp = BTN_PAD,
flipY: Boolean = false,
bg: Color = BtnBg,
tint: Color = BtnInk,
onClick: () -> Unit
) {
Box(
modifier = modifier
.size(BTN)
.clip(RoundedCornerShape(BTN_RADIUS))
.background(bg)
.focusProperties { canFocus = false }
.clickable(enabled = enabled, onClick = onClick)
.padding(iconPad),
contentAlignment = Alignment.Center
) {
Icon(
icon,
contentDescription = desc,
modifier = Modifier
.size(iconSize)
.graphicsLayer { scaleY = if (flipY) -1f else 1f },
tint = tint.copy(alpha = if (enabled) 1f else 0.3f)
)
}
}
data class MenuItem(
val icon: ImageVector,
val desc: String,
val enabled: Boolean = true,
val flipY: Boolean = false,
val keepOpen: Boolean = false,
val iconSize: Dp = BTN_ICON,
val bg: Color = BtnBg,
val onClick: () -> Unit
)
@Composable
private fun StackSlot(
visible: Boolean,
from: Alignment.Vertical = Alignment.Top,
content: @Composable () -> Unit
) {
AnimatedVisibility(
visible = visible,
enter = expandVertically(tween(ANIM_IN), from) + fadeIn(tween(FADE_IN)),
exit = shrinkVertically(tween(ANIM_OUT), from) + fadeOut(tween(FADE_OUT))
) {
Box(Modifier.padding(top = GAP)) { content() }
}
}
@Composable
internal fun MenuStack(
open: Boolean,
onOpenChange: (Boolean) -> Unit,
items: List<MenuItem>
) {
val change by rememberUpdatedState(onOpenChange)
var touch by remember { mutableIntStateOf(0) }
LaunchedEffect(open, touch) {
if (!open) return@LaunchedEffect
delay(VarnishConfig.MENU_IDLE_MS)
change(false)
}
Column(
modifier = Modifier.pointerInput(Unit) {
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
touch++
}
},
horizontalAlignment = Alignment.CenterHorizontally
) {
VButton(Icons.Outlined.Gradient, "Menu", iconSize = MENU_ICON) { change(!open) }
items.forEach { m ->
StackSlot(open) {
VButton(
m.icon,
m.desc,
enabled = m.enabled,
flipY = m.flipY,
iconSize = m.iconSize,
bg = m.bg
) {
if (!m.keepOpen) change(false)
m.onClick()
}
}
}
}
}
@Composable
internal fun TopBar(
modifier: Modifier = Modifier,
onBack: (() -> Unit)? = null,
trailing: (@Composable () -> Unit)? = null,
field: @Composable RowScope.() -> Unit
) {
Row(
modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = EDGE, vertical = GAP),
horizontalArrangement = Arrangement.spacedBy(GAP),
verticalAlignment = Alignment.Top
) {
if (onBack != null) {
VButton(
Icons.AutoMirrored.Filled.ArrowBack,
"Back",
onClick = onBack
)
}
field()
trailing?.invoke()
}
}
@Composable
private fun FieldFrame(
modifier: Modifier = Modifier,
content: @Composable BoxScope.() -> Unit
) {
Box(
modifier = modifier
.height(BTN)
.clip(RoundedCornerShape(BTN_RADIUS))
.background(FieldBg)
.border(1.dp, COutline, RoundedCornerShape(BTN_RADIUS)),
content = content
)
}
@Composable
internal fun SearchField(
query: String,
onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
val focus = LocalFocusManager.current
val style = TextStyle(FieldInk, fontSize = 17.sp, fontFamily = FontFamily.Monospace)
FieldFrame(modifier) {
Icon(
Icons.Outlined.Search,
contentDescription = null,
tint = FieldInk.copy(alpha = LABEL_ALPHA),
modifier = Modifier
.align(Alignment.CenterStart)
.padding(start = 14.dp)
.size(26.dp)
)
BasicTextField(
value = query,
onValueChange = onQueryChange,
modifier = Modifier
.fillMaxWidth()
.padding(start = 48.dp, end = 48.dp)
.align(Alignment.Center),
textStyle = style,
cursorBrush = SolidColor(FieldInk),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { focus.clearFocus() }),
singleLine = true,
decorationBox = { inner ->
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterStart) {
if (query.isEmpty()) {
Text("Search", style = style.copy(color = FieldInk.copy(alpha = LABEL_ALPHA)))
}
inner()
}
}
)
IconButton(
onClick = { if (query.isEmpty()) focus.clearFocus() else onQueryChange("") },
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 3.dp)
) {
Icon(Icons.Default.Clear, null, tint = FieldInk.copy(alpha = 0.4f))
}
}
}
@Composable
internal fun TitlePlate(
title: String,
modifier: Modifier = Modifier,
editable: Boolean = false,
suffix: String = "",
dim: Boolean = true,
align: TextAlign = TextAlign.Start,
onCommit: (String) -> Unit = {}
) {
val focus = LocalFocusManager.current
val full = title + suffix
var tv by remember(full) { mutableStateOf(TextFieldValue(full)) }
var focused by remember { mutableStateOf(false) }
val style = TextStyle(
FieldInk.copy(alpha = if (focused || !dim) 1f else LABEL_ALPHA),
fontSize = BAR_LABEL,
letterSpacing = 1.4.sp
)
Row(
modifier
.height(BTN)
.clip(RoundedCornerShape(BTN_RADIUS))
.background(FieldBg)
.border(1.dp, COutline, RoundedCornerShape(BTN_RADIUS))
.padding(horizontal = BAR_LABEL_PAD),
verticalAlignment = Alignment.CenterVertically
) {
if (!editable) {
Text(
full,
style = style,
textAlign = align,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
} else {
BasicTextField(
value = tv,
onValueChange = { v ->
if ('\n' in v.text || !v.text.endsWith(suffix)) return@BasicTextField
val cap = v.text.length - suffix.length
tv = v.copy(
selection = TextRange(
v.selection.start.coerceAtMost(cap),
v.selection.end.coerceAtMost(cap)
)
)
},
modifier = Modifier
.weight(1f)
.onFocusChanged { st ->
if (focused && !st.isFocused) {
val t = tv.text.dropLast(suffix.length).trim()
if (t.isNotEmpty() && t != title) onCommit(t)
tv = TextFieldValue(full)
}
focused = st.isFocused
},
textStyle = style,
cursorBrush = SolidColor(FieldInk),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focus.clearFocus() })
)
}
}
}
@Composable
internal fun FooterBubble(text: String, modifier: Modifier = Modifier) {
val shape = RoundedCornerShape(BUBBLE_RADIUS)
Box(
modifier
.height(36.dp)
.clip(shape)
.background(FieldBg)
.border(1.dp, COutline, shape)
.padding(horizontal = 14.dp),
contentAlignment = Alignment.Center
) {
Text(
text,
color = FieldInk,
fontSize = 14.sp,
fontFamily = FontFamily.Monospace,
maxLines = 1
)
}
}
@Composable
internal fun BoxScope.Dock(content: @Composable RowScope.() -> Unit) {
Row(
Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.padding(bottom = DOCK_PAD),
horizontalArrangement = Arrangement.spacedBy(GAP),
verticalAlignment = Alignment.CenterVertically,
content = content
)
}
internal fun Context.startOr(intent: Intent?, fallback: () -> Unit) {
if (intent == null || runCatching { startActivity(intent) }.isFailure) fallback()
}
@Composable
internal fun rememberArmed(): MutableState<Boolean> {
val s = remember { mutableStateOf(false) }
LaunchedEffect(s.value) {
if (s.value) {
delay(VarnishConfig.ARM_MS)
s.value = false
}
}
return s
}