package com.vgmlr.shim
import android.content.Intent
import android.graphics.Color as AndroidColor
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.ExperimentalFoundationApi
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.filled.*
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Lightbulb
import androidx.compose.material.icons.outlined.Park
import androidx.compose.material.icons.outlined.Save
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.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import androidx.compose.material.icons.outlined.WarningAmber
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.material.icons.automirrored.outlined.Login
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.text.TextRange
import android.content.ClipData
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalUriHandler
private const val IME_GRACE = 600L
private const val IME_WAIT = 2500L
private const val IME_SETTLE = 250L
private const val SAVE_HOLD = 2000L
class ShimMainActivity : ComponentActivity() {
private lateinit var db: ShimDatabase
private lateinit var themeManager: ShimThemeManager
private var shimListState = mutableStateOf<List<ShimClass>>(emptyList())
private val tagSetsState = mutableStateOf<List<String>>(emptyList())
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.setBackgroundDrawableResource(R.color.background_color)
db = ShimDatabase(this)
themeManager = ShimThemeManager(this)
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.dark(AndroidColor.TRANSPARENT),
navigationBarStyle = SystemBarStyle.dark(AndroidColor.TRANSPARENT)
)
setContent {
ShimTheme(themeManager) {
MainScreen()
}
}
}
override fun onResume() {
super.onResume()
themeManager.refresh()
refreshList()
}
private fun refreshList() {
shimListState.value = db.getAllShims()
tagSetsState.value = db.getTagSets()
}
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalFoundationApi::class,
ExperimentalLayoutApi::class
)
@Composable
fun MainScreen() {
val lifecycleOwner = LocalLifecycleOwner.current
val windowInfo = LocalWindowInfo.current
var searchQuery by remember { mutableStateOf("") }
var isSearchExpanded by remember { mutableStateOf(false) }
val imeState = rememberUpdatedState(WindowInsets.isImeVisible)
var resumeTick by remember { mutableIntStateOf(0) }
var showTagsDialog by remember { mutableStateOf(false) }
var showVersionDialog by remember { mutableStateOf(false) }
var saveStatus by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
var shimText by remember { mutableStateOf("") }
var hashtagText by remember { mutableStateOf(TextFieldValue("")) }
var editId by remember { mutableStateOf<Int?>(null) }
val allTags = remember(tagSetsState.value) {
tagSetsState.value.flatMap {
it.split(" ").filter { tag -> tag.isNotBlank() }
}.distinct().sorted()
}
val topTags = remember(tagSetsState.value) {
tagSetsState.value.flatMap {
it.split(" ").filter { tag -> tag.isNotBlank() }
}
.groupingBy { it }
.eachCount()
.entries
.sortedByDescending { it.value }
.take(5)
.map { it.key }
}
val tagAssociations = remember(tagSetsState.value) {
val associations = mutableMapOf<String, MutableSet<String>>()
tagSetsState.value.forEach { set ->
val tags = set.split(" ").filter { it.isNotBlank() }
tags.forEach { tag ->
val associated = associations.getOrPut(tag) { mutableSetOf() }
associated.addAll(tags.filter { it != tag })
}
}
associations.mapValues { it.value.sorted() }
}
var marked by remember { mutableStateOf(emptySet<Int>()) }
var armDel by remember { mutableStateOf(false) }
val markedShims = shimListState.value.filter { it.id in marked }
val rail = remember(shimListState.value) { railFor(db.maxId()) }
val clipboard = LocalClipboard.current
val uriHandler = LocalUriHandler.current
fun reload() {
shimListState.value =
if (searchQuery.isEmpty()) db.getAllShims()
else db.searchShims(searchQuery)
}
fun save(subset: List<ShimClass>?, keyword: String? = null) {
scope.launch {
saveStatus = if (ShimSave.save(
this@ShimMainActivity,
db,
subset,
keyword
)
) "Saved" else "Failed"
}
}
LaunchedEffect(saveStatus) {
if (saveStatus == null) return@LaunchedEffect
delay(SAVE_HOLD)
saveStatus = null
}
LaunchedEffect(shimListState.value) {
val live = shimListState.value.mapTo(HashSet()) { it.id }
if (marked.any { it !in live }) marked = marked.filterTo(HashSet()) { it in live }
}
LaunchedEffect(marked) { armDel = false }
LaunchedEffect(armDel) {
if (!armDel) return@LaunchedEffect
delay(ARM_HOLD)
armDel = false
}
LaunchedEffect(isSearchExpanded) {
if (!isSearchExpanded || editId == null) return@LaunchedEffect
editId = null
shimText = ""
hashtagText = TextFieldValue("")
}
BackHandler(enabled = marked.isNotEmpty()) { marked = emptySet() }
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
resumeTick++
isSearchExpanded = false
searchQuery = ""
marked = emptySet()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
LaunchedEffect(isSearchExpanded, resumeTick) {
if (isSearchExpanded) return@LaunchedEffect
snapshotFlow { windowInfo.isWindowFocused }.first { it }
delay(IME_GRACE)
withTimeoutOrNull(IME_WAIT) {
snapshotFlow { imeState.value }.first { it }
} ?: return@LaunchedEffect
snapshotFlow { imeState.value }.first { !it }
delay(IME_SETTLE)
if (!imeState.value) isSearchExpanded = true
}
val importLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri?.let {
if (db.importFile(this@ShimMainActivity, it)) refreshList()
else Toast.makeText(
this@ShimMainActivity,
"Unsupported file",
Toast.LENGTH_SHORT
).show()
}
}
val topInset = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
Box(modifier = Modifier.fillMaxSize()) {
Scaffold(
contentWindowInsets = WindowInsets(0, 0, 0, 0)
) { p ->
val navBottom =
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val listTop = topInset + BAR_GAP + SEARCH_FIELD_H
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.padding(bottom = p.calculateBottomPadding())
.background(themeManager.backgroundColor.value)
.drawBehind {
val x = rail.x.toPx()
drawLine(
color = themeManager.textColor.value.copy(alpha = RAIL_ALPHA),
start = Offset(x, 0f),
end = Offset(x, size.height),
strokeWidth = RAIL_W.toPx()
)
}
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { marked = emptySet() },
contentPadding = PaddingValues(
top = listTop + LIST_PAD_TOP,
bottom = navBottom + LIST_PAD_V + BTN_SIZE + DOCK_PAD * 2
)
) {
items(shimListState.value, key = { it.id }) { shim ->
ShimItem(
shim = shim,
themeManager = themeManager,
railX = rail.x,
railGap = rail.gap,
isSelected = shim.id in marked,
onHashtagClick = { tag ->
searchQuery = tag
isSearchExpanded = true
shimListState.value = db.searchShims(tag)
},
onNumClick = { marked = marked.toggle(shim.id) }
)
}
}
}
Row(
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.padding(top = topInset + BAR_GAP, start = BAR_EDGE, end = BAR_EDGE_ACTIONS),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(BAR_GAP)
) {
Box(Modifier.weight(1f)) {
SearchField(
query = searchQuery,
status = saveStatus,
onQueryChange = {
searchQuery = it
shimListState.value =
if (it.isEmpty()) db.getAllShims() else db.searchShims(it)
},
onClose = {
isSearchExpanded = false
searchQuery = ""
refreshList()
},
onDownload = {
save(shimListState.value, searchQuery)
}
)
}
ShimButton(Icons.Default.Tag, "Tags") { showTagsDialog = true }
Box {
var expanded by remember { mutableStateOf(false) }
ShimButton(
Icons.Outlined.Lightbulb,
"Menu",
iconSize = BTN_ICON + 2.dp,
iconPad = BTN_PAD - 1.dp
) { expanded = true }
ShimMenu(
expanded = expanded,
onDismiss = { expanded = false }
) {
ShimMenuItem("Import", Icons.AutoMirrored.Outlined.Login) {
importLauncher.launch("*/*")
expanded = false
}
ShimMenuItem(
"Save",
Icons.Outlined.Save,
iconSize = BTN_ICON + 2.dp,
iconPad = BTN_PAD - 1.dp
) {
save(null)
expanded = false
}
ShimMenuItem("Settings", Icons.Outlined.Settings) {
startActivity(Intent(this@ShimMainActivity, ShimSettings::class.java))
expanded = false
}
ShimMenuItem("Version", Icons.Outlined.Park) {
showVersionDialog = true
expanded = false
}
ShimMenuItem("Exit", Icons.Default.Block, enabled = false) {}
}
}
}
if (markedShims.isNotEmpty()) {
val single = markedShims.singleOrNull()
ShimDock {
ShimButton(Icons.Default.Create, "Edit", enabled = single != null) {
single?.let { s ->
editId = s.id
shimText = s.shimtext
hashtagText = TextFieldValue(s.shimhash, TextRange(s.shimhash.length))
searchQuery = ""
refreshList()
isSearchExpanded = false
}
marked = emptySet()
}
ShimButton(
Icons.Default.ContentCopy,
"Copy",
iconSize = BTN_ICON_SM,
enabled = single != null
) {
single?.let { s ->
scope.launch {
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("shim", s.shimtext)))
}
}
marked = emptySet()
}
ShimButton(Icons.Default.Share, "Share", enabled = single != null) {
single?.let { s ->
startActivity(
Intent.createChooser(
Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, s.shimtext)
},
null
)
)
}
marked = emptySet()
}
ShimButton(
if (armDel) Icons.Outlined.WarningAmber else Icons.Outlined.Delete,
"Delete",
iconSize = BTN_ICON_LG,
iconPad = BTN_PAD_LG,
bg = if (armDel) WARN_BG else Color.Unspecified
) {
if (armDel) {
db.deleteShims(markedShims.map { it.id })
marked = emptySet()
reload()
} else armDel = true
}
}
}
if (!isSearchExpanded) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.8f))
.clickable { isSearchExpanded = true }
)
}
if (!isSearchExpanded) {
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.imePadding()
) {
InputBar(
shimText = shimText,
onShimTextChange = { shimText = it },
hashtagText = hashtagText,
allTags = allTags,
topTags = topTags,
tagAssociations = tagAssociations,
onHashtagTextChange = { hashtagText = it },
onAdd = { text, tags ->
val id = editId
if (id == null) db.addShim(text, tags.text, ShimOTCClock.getCurrentOTCTime())
else db.updateShim(id, text, tags.text)
editId = null
shimText = ""
hashtagText = TextFieldValue("")
refreshList()
val at = if (id == null) 0
else shimListState.value.indexOfFirst { it.id == id }.coerceAtLeast(0)
scope.launch { listState.scrollToItem(at) }
},
themeManager = themeManager
)
}
}
if (showTagsDialog) {
TagsDialog(
tagSets = tagSetsState.value,
onDismiss = { showTagsDialog = false },
onTagClick = { tag ->
searchQuery = tag
isSearchExpanded = true
shimListState.value = db.searchShims(tag)
}
)
}
VersionDialog(
show = showVersionDialog,
onDismiss = { showVersionDialog = false },
uriHandler = uriHandler
)
}
}
}
private fun Set<Int>.toggle(id: Int): Set<Int> = if (id in this) this - id else this + id