OXYGENHUB
# 74bbbc259910 oxygenhub-android / app / src / main / java / io / oxygenhub / android / ui / proposals / ProposalViewModel.kt
4.3 KiB · last changed Land proposals from the phone (closes #17) 10 days ago source blame history raw
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
}