| 1 |
package io.oxygenhub.android.ui.proposals |
| 2 |
|
| 3 |
import androidx.lifecycle.ViewModel |
| 4 |
import androidx.lifecycle.viewModelScope |
| 5 |
import io.oxygenhub.android.api.Check |
| 6 |
import io.oxygenhub.android.api.HubApi |
| 7 |
import io.oxygenhub.android.api.HubException |
| 8 |
import io.oxygenhub.android.api.Proposal |
| 9 |
import io.oxygenhub.android.api.WriteScope |
| 10 |
import io.oxygenhub.android.api.lackedScope |
| 11 |
import io.oxygenhub.android.api.userMessage |
| 12 |
import io.oxygenhub.android.ui.common.Load |
| 13 |
import io.oxygenhub.android.ui.common.load |
| 14 |
import kotlinx.coroutines.CancellationException |
| 15 |
import kotlinx.coroutines.Job |
| 16 |
import kotlinx.coroutines.async |
| 17 |
import kotlinx.coroutines.coroutineScope |
| 18 |
import kotlinx.coroutines.flow.MutableStateFlow |
| 19 |
import kotlinx.coroutines.flow.StateFlow |
| 20 |
import kotlinx.coroutines.flow.asStateFlow |
| 21 |
import kotlinx.coroutines.launch |
| 22 |
|
| 23 |
/** A proposal and the checks on what it would land; [checks] is null when they could not be read. */ |
| 24 |
data class ProposalPage(val proposal: Proposal, val checks: List<Check>?) |
| 25 |
|
| 26 |
/** What landing (or setting it to land by itself) is doing, or did. */ |
| 27 |
sealed interface LandAction { |
| 28 |
data object Idle : LandAction |
| 29 |
data object Working : LandAction |
| 30 |
data class Done(val message: String) : LandAction |
| 31 |
|
| 32 |
/** [needsWrite]: the token may not change things; signing in again asks for it. */ |
| 33 |
data class Failed(val message: String, val needsWrite: Boolean = false) : LandAction |
| 34 |
} |
| 35 |
|
| 36 |
/** One proposal, with its checks; lands it, or sets it to land when its checks pass. */ |
| 37 |
class ProposalViewModel( |
| 38 |
private val api: HubApi, |
| 39 |
val owner: String, |
| 40 |
val name: String, |
| 41 |
val number: Long, |
| 42 |
) : ViewModel() { |
| 43 |
private val _state = MutableStateFlow<Load<ProposalPage>>(Load.Loading) |
| 44 |
val state: StateFlow<Load<ProposalPage>> = _state.asStateFlow() |
| 45 |
|
| 46 |
private val _action = MutableStateFlow<LandAction>(LandAction.Idle) |
| 47 |
val action: StateFlow<LandAction> = _action.asStateFlow() |
| 48 |
|
| 49 |
private var job: Job? = null |
| 50 |
|
| 51 |
init { |
| 52 |
refresh() |
| 53 |
} |
| 54 |
|
| 55 |
fun refresh() { |
| 56 |
job = load(_state, job) { |
| 57 |
coroutineScope { |
| 58 |
// The checks are part of the picture, not the page: without them it still shows. |
| 59 |
val checks = async { |
| 60 |
try { |
| 61 |
api.checks(owner, name, number) |
| 62 |
} catch (e: CancellationException) { |
| 63 |
throw e |
| 64 |
} catch (_: Exception) { |
| 65 |
null |
| 66 |
} |
| 67 |
} |
| 68 |
ProposalPage(api.proposal(owner, name, number), checks.await()) |
| 69 |
} |
| 70 |
} |
| 71 |
} |
| 72 |
|
| 73 |
fun land() = act { p -> |
| 74 |
val landed = api.land(owner, name, number) |
| 75 |
landed to "Landed #$number into ${p.target}." |
| 76 |
} |
| 77 |
|
| 78 |
fun setAutoLand(on: Boolean) = act { _ -> |
| 79 |
val after = api.setAutoLand(owner, name, number, on) |
| 80 |
after to if (on) { |
| 81 |
if (after.landed != null) "Its checks had passed: landed #$number." else "#$number will land when its checks pass." |
| 82 |
} else { |
| 83 |
"#$number will not land by itself." |
| 84 |
} |
| 85 |
} |
| 86 |
|
| 87 |
fun dismiss() { |
| 88 |
_action.value = LandAction.Idle |
| 89 |
} |
| 90 |
|
| 91 |
private fun act(work: suspend (Proposal) -> Pair<Proposal, String>) { |
| 92 |
val page = _state.value.valueOrNull ?: return |
| 93 |
if (_action.value == LandAction.Working) return |
| 94 |
_action.value = LandAction.Working |
| 95 |
viewModelScope.launch { |
| 96 |
_action.value = try { |
| 97 |
val (after, message) = work(page.proposal) |
| 98 |
_state.value = Load.Ready(page.copy(proposal = after)) |
| 99 |
LandAction.Done(message) |
| 100 |
} catch (e: CancellationException) { |
| 101 |
throw e |
| 102 |
} catch (e: Exception) { |
| 103 |
failure(e) |
| 104 |
} |
| 105 |
// What the hub thinks now: blockers, checks, who set auto-land. |
| 106 |
refresh() |
| 107 |
} |
| 108 |
} |
| 109 |
|
| 110 |
companion object { |
| 111 |
fun failure(e: Exception): LandAction.Failed = when { |
| 112 |
e.lackedScope == WriteScope -> LandAction.Failed( |
| 113 |
"This phone was signed in to read only. Sign in again to allow landing.", |
| 114 |
needsWrite = true, |
| 115 |
) |
| 116 |
// The hub's own reason: blockers, a conflict, a race with another landing. |
| 117 |
e is HubException.Http && e.status == 409 -> LandAction.Failed(e.detail) |
| 118 |
else -> LandAction.Failed(e.userMessage()) |
| 119 |
} |
| 120 |
} |
| 121 |
} |