package com.vgmlr.kiln
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.time.LocalDate
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.StateFlow
import android.net.Uri
import androidx.room.withTransaction
import kotlinx.coroutines.withContext
data class AccountSummary(
val kilnAccount: KilnAccount,
val daysToPreMenstrual: Long?,
val todayHue: Float,
val isDefault: Boolean
)
@OptIn(ExperimentalCoroutinesApi::class)
class KilnViewModel(app: Application) : AndroidViewModel(app) {
private val db = KilnDb.get(app)
private val dao = db.dayRecordDao()
private val accountDao = db.accountDao()
private val buttonDao = db.buttonDao()
private val prefs = KilnPrefs.get(app)
val defaultAccountId = prefs.defaultAccountId
val loadDefaultOnStart = prefs.loadDefaultOnStart
val accounts = accountDao.observeAll()
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
val allRecords = dao.observeEvery()
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
private val allButtons = buttonDao.observeEvery()
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
val summaries = combine(
accounts, allRecords, allButtons, defaultAccountId
) { accts, recs, btns, defId ->
val byAccount = recs.groupBy { it.accountId }
val buttonsByAccount = btns.groupBy { it.accountId }
val today = LocalDate.now()
accts.map { a ->
val r = byAccount[a.id].orEmpty()
val set = KilnButtonSet(buttonsByAccount[a.id].orEmpty())
val starts = KilnCyclePredictor.periodStarts(r, set)
val cycle = KilnCyclePredictor.avgCycleLength(starts)
AccountSummary(
a,
KilnDayStats.preMenstrualIn(today, r, set),
KilnGradientPalette.lookup(r, set, starts, cycle)(today),
a.id == defId
)
}
}.flowOn(Dispatchers.Default)
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
private val activeId = MutableStateFlow(KilnPrefs.NO_ACCOUNT)
val activeKilnAccount: StateFlow<KilnAccount?> =
combine(accounts, activeId) { list, id -> list.firstOrNull { it.id == id } }
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
val selectedDate = MutableStateFlow(LocalDate.now())
val records = activeId
.flatMapLatest { id ->
if (id == KilnPrefs.NO_ACCOUNT) flowOf(emptyList()) else dao.observeAll(id)
}
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
val buttons = activeId
.flatMapLatest { id ->
if (id == KilnPrefs.NO_ACCOUNT) flowOf(emptyList()) else buttonDao.observeAll(id)
}
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
val selectedRecord = combine(activeId, selectedDate) { id, d -> id to d }
.flatMapLatest { (id, d) ->
if (id == KilnPrefs.NO_ACCOUNT) flowOf(null) else dao.observeDay(id, d.toEpochDay())
}
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
fun open(kilnAccount: KilnAccount) {
activeId.value = kilnAccount.id
selectedDate.value = LocalDate.now()
}
fun addAccount(name: String) {
viewModelScope.launch {
val id = accountDao.insert(KilnAccount(name = name.trim()))
buttonDao.insertAll(KilnDefaultButtons.forAccount(id))
if (prefs.defaultAccountId.value == KilnPrefs.NO_ACCOUNT) prefs.setDefaultAccount(id)
}
}
fun deleteActiveAccount() {
val account = activeKilnAccount.value ?: return
activeId.value = KilnPrefs.NO_ACCOUNT
if (prefs.defaultAccountId.value == account.id) {
prefs.setDefaultAccount(KilnPrefs.NO_ACCOUNT)
}
viewModelScope.launch { accountDao.delete(account) }
}
fun setHair(hair: KilnHair) = updateAccount { it.copy(hair = hair.name) }
fun setPmsAlert(on: Boolean) = updateAccount { it.copy(pmsAlert = on) }
fun setPmsMessage(text: String) = updateAccount { it.copy(pmsMessage = text) }
fun setMenstruationAlert(on: Boolean) = updateAccount {
it.copy(menstruationAlert = on, menstruationReminder = it.menstruationReminder && on)
}
fun setMenstruationMessage(text: String) = updateAccount {
it.copy(menstruationMessage = text)
}
fun setMenstruationReminder(on: Boolean) = updateAccount { it.copy(menstruationReminder = on) }
private fun updateAccount(transform: (KilnAccount) -> KilnAccount) {
val account = activeKilnAccount.value ?: return
viewModelScope.launch { accountDao.update(transform(account)) }
}
fun addButton(label: String, kind: KilnButtonKind) {
val id = activeKilnAccount.value?.id ?: return
val sort = (buttons.value.maxOfOrNull { it.sort } ?: -1) + 1
viewModelScope.launch {
buttonDao.insert(
KilnButton(accountId = id, label = label.trim(), kind = kind.value, sort = sort)
)
}
}
fun updateButton(button: KilnButton, label: String, kind: KilnButtonKind) {
viewModelScope.launch {
buttonDao.update(button.copy(label = label.trim(), kind = kind.value))
}
}
fun deleteButton(button: KilnButton) {
viewModelScope.launch { buttonDao.delete(button) }
}
fun moveButton(button: KilnButton, delta: Int) {
val list = buttons.value.toMutableList()
val i = list.indexOfFirst { it.id == button.id }
val j = i + delta
if (i < 0 || j !in list.indices) return
val held = list[i]
list[i] = list[j]
list[j] = held
val changed = list.mapIndexedNotNull { k, b -> if (b.sort == k) null else b.copy(sort = k) }
if (changed.isEmpty()) return
viewModelScope.launch { buttonDao.updateAll(changed) }
}
fun toggle(buttonId: Long) = edit { it.toggled(buttonId) }
fun setDefaultAccount(id: Long) = prefs.setDefaultAccount(id)
fun exportActive(uri: Uri) {
val account = activeKilnAccount.value ?: return
val btns = buttons.value
val recs = records.value
viewModelScope.launch {
withContext(Dispatchers.IO) {
KilnTransfer.write(
getApplication(), uri, KilnTransfer.encode(account, btns, recs)
)
}
}
}
fun importFrom(uri: Uri, onResult: (Boolean) -> Unit) {
val ctx = getApplication<Application>()
viewModelScope.launch {
val bundle = withContext(Dispatchers.IO) {
if (!KilnTransfer.isKilnFile(KilnTransfer.displayName(ctx, uri))) return@withContext null
KilnTransfer.read(ctx, uri)?.let(KilnTransfer::decode)
}
if (bundle == null) {
onResult(false)
return@launch
}
insertBundle(bundle)
onResult(true)
}
}
private suspend fun insertBundle(bundle: KilnBundle) = db.withTransaction {
val id = accountDao.insert(bundle.account)
val map = HashMap<Long, Long>()
for (b in bundle.buttons) map[b.id] = buttonDao.insert(b.copy(id = 0, accountId = id))
dao.upsertAll(
bundle.records.map { r ->
r.copy(accountId = id, checked = r.checked.mapNotNullTo(HashSet()) { map[it] })
}
)
if (prefs.defaultAccountId.value == KilnPrefs.NO_ACCOUNT) prefs.setDefaultAccount(id)
}
fun setLoadDefaultOnStart(enabled: Boolean) = prefs.setLoadDefaultOnStart(enabled)
fun select(date: LocalDate) { selectedDate.value = date }
fun edit(transform: (KilnDayRecord) -> KilnDayRecord) {
val id = activeKilnAccount.value?.id ?: return
val day = selectedDate.value.toEpochDay()
viewModelScope.launch {
val current = records.value.find { it.epochDay == day } ?: KilnDayRecord(id, day)
dao.upsert(transform(current))
}
}
fun iconRecords(): List<KilnDayRecord> {
val id = KilnIconSync.iconAccount(accounts.value, prefs.defaultAccountId.value)?.id
?: return emptyList()
return allRecords.value.filter { it.accountId == id }
}
fun iconButtons(): KilnButtonSet {
val id = KilnIconSync.iconAccount(accounts.value, prefs.defaultAccountId.value)?.id
?: return KilnButtonSet.EMPTY
return KilnButtonSet(allButtons.value.filter { it.accountId == id })
}
}