| 1 |
package io.oxygenhub.android.ui.issues |
| 2 |
|
| 3 |
import androidx.lifecycle.ViewModel |
| 4 |
import androidx.lifecycle.viewModelScope |
| 5 |
import io.oxygenhub.android.api.Comment |
| 6 |
import io.oxygenhub.android.api.HubApi |
| 7 |
import io.oxygenhub.android.api.Issue |
| 8 |
import io.oxygenhub.android.api.Label |
| 9 |
import io.oxygenhub.android.api.userMessage |
| 10 |
import io.oxygenhub.android.ui.common.Load |
| 11 |
import io.oxygenhub.android.ui.common.load |
| 12 |
import kotlinx.coroutines.CancellationException |
| 13 |
import kotlinx.coroutines.Job |
| 14 |
import kotlinx.coroutines.async |
| 15 |
import kotlinx.coroutines.coroutineScope |
| 16 |
import kotlinx.coroutines.flow.MutableStateFlow |
| 17 |
import kotlinx.coroutines.flow.StateFlow |
| 18 |
import kotlinx.coroutines.flow.asStateFlow |
| 19 |
import kotlinx.coroutines.launch |
| 20 |
|
| 21 |
/** |
| 22 |
* One issue (#4): it and its comments; comment, close or reopen, and set |
| 23 |
* its labels. What the hub refuses is said in [error], and the page stays. |
| 24 |
*/ |
| 25 |
class IssueViewModel( |
| 26 |
private val api: HubApi, |
| 27 |
val owner: String, |
| 28 |
val name: String, |
| 29 |
val number: Long, |
| 30 |
) : ViewModel() { |
| 31 |
data class Thread(val issue: Issue, val comments: List<Comment>) |
| 32 |
|
| 33 |
private val _state = MutableStateFlow<Load<Thread>>(Load.Loading) |
| 34 |
val state: StateFlow<Load<Thread>> = _state.asStateFlow() |
| 35 |
|
| 36 |
private val _working = MutableStateFlow(false) |
| 37 |
val working: StateFlow<Boolean> = _working.asStateFlow() |
| 38 |
|
| 39 |
private val _error = MutableStateFlow<String?>(null) |
| 40 |
val error: StateFlow<String?> = _error.asStateFlow() |
| 41 |
|
| 42 |
/** The repository's labels, once the label picker asks. */ |
| 43 |
private val _labels = MutableStateFlow<Load<List<Label>>>(Load.Loading) |
| 44 |
val labels: StateFlow<Load<List<Label>>> = _labels.asStateFlow() |
| 45 |
|
| 46 |
private var job: Job? = null |
| 47 |
private var labelsJob: Job? = null |
| 48 |
|
| 49 |
init { |
| 50 |
refresh() |
| 51 |
} |
| 52 |
|
| 53 |
fun refresh() { |
| 54 |
job = load(_state, job) { |
| 55 |
coroutineScope { |
| 56 |
val issue = async { api.issue(owner, name, number) } |
| 57 |
val comments = async { api.issueComments(owner, name, number) } |
| 58 |
Thread(issue.await(), comments.await()) |
| 59 |
} |
| 60 |
} |
| 61 |
} |
| 62 |
|
| 63 |
fun loadLabels() { |
| 64 |
if (_labels.value is Load.Ready) return |
| 65 |
labelsJob = load(_labels, labelsJob) { api.labels(owner, name) } |
| 66 |
} |
| 67 |
|
| 68 |
/** Post [body]; true once the hub has it (the box empties then). */ |
| 69 |
fun comment(body: String, then: (Boolean) -> Unit = {}) = act({ |
| 70 |
val c = api.comment(owner, name, number, body.trim()) |
| 71 |
current()?.let { t -> _state.value = Load.Ready(t.copy(comments = t.comments + c, issue = t.issue.copy(comments = t.issue.comments + 1))) } |
| 72 |
}, then) |
| 73 |
|
| 74 |
fun close(reason: String = "completed") = act({ swap(api.setIssueState(owner, name, number, open = false, reason = reason)) }) |
| 75 |
|
| 76 |
fun reopen() = act({ swap(api.setIssueState(owner, name, number, open = true)) }) |
| 77 |
|
| 78 |
fun setLabels(names: List<String>) = act({ swap(api.setIssueLabels(owner, name, number, names)) }) |
| 79 |
|
| 80 |
/** Who the issue is assigned to, as the whole set (#4): names trimmed, `@` dropped, each once. */ |
| 81 |
fun setAssignees(names: List<String>) = |
| 82 |
act({ swap(api.setIssueAssignees(owner, name, number, names.map { it.trim().removePrefix("@") }.filter { it.isNotEmpty() }.distinct())) }) |
| 83 |
|
| 84 |
fun dismissError() { |
| 85 |
_error.value = null |
| 86 |
} |
| 87 |
|
| 88 |
private fun current(): Thread? = (_state.value as? Load.Ready)?.value |
| 89 |
|
| 90 |
private fun swap(issue: Issue) { |
| 91 |
current()?.let { _state.value = Load.Ready(it.copy(issue = issue)) } |
| 92 |
} |
| 93 |
|
| 94 |
private fun act(step: suspend () -> Unit, then: (Boolean) -> Unit = {}) { |
| 95 |
if (_working.value) return |
| 96 |
_working.value = true |
| 97 |
_error.value = null |
| 98 |
viewModelScope.launch { |
| 99 |
val ok = try { |
| 100 |
step() |
| 101 |
true |
| 102 |
} catch (e: CancellationException) { |
| 103 |
throw e |
| 104 |
} catch (e: Exception) { |
| 105 |
_error.value = e.userMessage() |
| 106 |
false |
| 107 |
} finally { |
| 108 |
_working.value = false |
| 109 |
} |
| 110 |
then(ok) |
| 111 |
} |
| 112 |
} |
| 113 |
} |
| 114 |
|
| 115 |
/** A new issue: its title and body, opened on the hub. */ |
| 116 |
class NewIssueViewModel( |
| 117 |
private val api: HubApi, |
| 118 |
val owner: String, |
| 119 |
val name: String, |
| 120 |
) : ViewModel() { |
| 121 |
private val _working = MutableStateFlow(false) |
| 122 |
val working: StateFlow<Boolean> = _working.asStateFlow() |
| 123 |
|
| 124 |
private val _error = MutableStateFlow<String?>(null) |
| 125 |
val error: StateFlow<String?> = _error.asStateFlow() |
| 126 |
|
| 127 |
fun open(title: String, body: String, opened: (Issue) -> Unit) { |
| 128 |
if (_working.value || title.isBlank()) return |
| 129 |
_working.value = true |
| 130 |
_error.value = null |
| 131 |
viewModelScope.launch { |
| 132 |
try { |
| 133 |
opened(api.createIssue(owner, name, title.trim(), body.trim())) |
| 134 |
} catch (e: CancellationException) { |
| 135 |
throw e |
| 136 |
} catch (e: Exception) { |
| 137 |
_error.value = e.userMessage() |
| 138 |
} finally { |
| 139 |
_working.value = false |
| 140 |
} |
| 141 |
} |
| 142 |
} |
| 143 |
} |