| 1 |
package io.oxygenhub.android.ui.dashboard |
| 2 |
|
| 3 |
import androidx.lifecycle.ViewModel |
| 4 |
import io.oxygenhub.android.api.Avatar |
| 5 |
import io.oxygenhub.android.api.HubApi |
| 6 |
import io.oxygenhub.android.api.IssueBrief |
| 7 |
import io.oxygenhub.android.api.Repository |
| 8 |
import io.oxygenhub.android.api.User |
| 9 |
import io.oxygenhub.android.api.UserSky |
| 10 |
import io.oxygenhub.android.api.UserProposals |
| 11 |
import io.oxygenhub.android.auth.Session |
| 12 |
import io.oxygenhub.android.ui.common.Avatars |
| 13 |
import io.oxygenhub.android.ui.common.Load |
| 14 |
import io.oxygenhub.android.ui.common.load |
| 15 |
import kotlinx.coroutines.CancellationException |
| 16 |
import kotlinx.coroutines.Job |
| 17 |
import kotlinx.coroutines.async |
| 18 |
import kotlinx.coroutines.coroutineScope |
| 19 |
import kotlinx.coroutines.flow.MutableStateFlow |
| 20 |
import kotlinx.coroutines.flow.StateFlow |
| 21 |
import kotlinx.coroutines.flow.asStateFlow |
| 22 |
import java.util.Calendar |
| 23 |
|
| 24 |
data class Dashboard( |
| 25 |
val greeting: String, |
| 26 |
/** The account, or null when the token may not read it (no `user:read`). */ |
| 27 |
val user: User?, |
| 28 |
/** The account's avatar (#13), or null while the hub has none this app can draw. */ |
| 29 |
val avatar: Avatar? = null, |
| 30 |
/** Whose avatar: the signed-in account's name. */ |
| 31 |
val username: String = "", |
| 32 |
/** Proposals to review and the account's own (#18); null when the hub cannot say. */ |
| 33 |
val proposals: UserProposals? = null, |
| 34 |
/** Open issues assigned to the account (#31); null when the hub cannot say. */ |
| 35 |
val assigned: List<IssueBrief>? = null, |
| 36 |
/** The newest-updated repositories: the user's own, or everyone's when they own none. */ |
| 37 |
val recent: List<Repository>, |
| 38 |
val recentAreYours: Boolean, |
| 39 |
val repositoryCount: Int, |
| 40 |
/** Your sky (#23), the backdrop's data; null when the hub cannot give it. */ |
| 41 |
val sky: UserSky? = null, |
| 42 |
) |
| 43 |
|
| 44 |
class DashboardViewModel( |
| 45 |
private val api: HubApi, |
| 46 |
private val session: Session, |
| 47 |
private val hourOfDay: () -> Int = { Calendar.getInstance().get(Calendar.HOUR_OF_DAY) }, |
| 48 |
) : ViewModel() { |
| 49 |
private val _state = MutableStateFlow<Load<Dashboard>>(Load.Loading) |
| 50 |
val state: StateFlow<Load<Dashboard>> = _state.asStateFlow() |
| 51 |
|
| 52 |
private var job: Job? = null |
| 53 |
|
| 54 |
init { |
| 55 |
refresh() |
| 56 |
} |
| 57 |
|
| 58 |
fun refresh() { |
| 59 |
job = load(_state, job) { fetch() } |
| 60 |
} |
| 61 |
|
| 62 |
/** |
| 63 |
* The dashboard is in front again (back from a proposal, or the app |
| 64 |
* brought back): fetch afresh, keeping what shows meanwhile, so a |
| 65 |
* proposal just landed no longer shows as open (#35). Not while the |
| 66 |
* first load is still on its way. |
| 67 |
*/ |
| 68 |
fun resumed() { |
| 69 |
if (_state.value !is Load.Loading) refresh() |
| 70 |
} |
| 71 |
|
| 72 |
private suspend fun fetch(): Dashboard = coroutineScope { |
| 73 |
// The account is a nicety (a display name); the repositories are the page. |
| 74 |
val user = async { |
| 75 |
try { |
| 76 |
api.user() |
| 77 |
} catch (e: CancellationException) { |
| 78 |
throw e |
| 79 |
} catch (_: Exception) { |
| 80 |
null |
| 81 |
} |
| 82 |
} |
| 83 |
// As are the proposals: a hub without the call shows none. |
| 84 |
val proposals = async { |
| 85 |
try { |
| 86 |
api.userProposals() |
| 87 |
} catch (e: CancellationException) { |
| 88 |
throw e |
| 89 |
} catch (_: Exception) { |
| 90 |
null |
| 91 |
} |
| 92 |
} |
| 93 |
// As are the issues assigned to it (a hub before oxygenhub#359 answers 404). |
| 94 |
val assigned = async { |
| 95 |
try { |
| 96 |
api.userIssues() |
| 97 |
} catch (e: CancellationException) { |
| 98 |
throw e |
| 99 |
} catch (_: Exception) { |
| 100 |
null |
| 101 |
} |
| 102 |
} |
| 103 |
// As is the sky behind it all. |
| 104 |
val sky = async { |
| 105 |
try { |
| 106 |
api.userSky() |
| 107 |
} catch (e: CancellationException) { |
| 108 |
throw e |
| 109 |
} catch (_: Exception) { |
| 110 |
null |
| 111 |
} |
| 112 |
} |
| 113 |
// As is the avatar: its failure leaves the last one, or the initial. |
| 114 |
val avatar = async { Avatars.load(api, session.username) } |
| 115 |
val repos = api.repositories() |
| 116 |
val me = user.await() |
| 117 |
val username = me?.username ?: session.username |
| 118 |
val mine = repos.filter { it.owner == username } |
| 119 |
val pool = mine.ifEmpty { repos } |
| 120 |
Dashboard( |
| 121 |
greeting = greeting(hourOfDay(), me?.shownName ?: session.username), |
| 122 |
user = me, |
| 123 |
avatar = avatar.await(), |
| 124 |
username = username, |
| 125 |
proposals = proposals.await(), |
| 126 |
assigned = assigned.await(), |
| 127 |
recent = pool.sortedByDescending { it.updatedMs }.take(RECENT), |
| 128 |
recentAreYours = mine.isNotEmpty(), |
| 129 |
repositoryCount = mine.size, |
| 130 |
sky = sky.await(), |
| 131 |
) |
| 132 |
} |
| 133 |
|
| 134 |
companion object { |
| 135 |
const val RECENT = 5 |
| 136 |
|
| 137 |
fun greeting(hour: Int, name: String): String { |
| 138 |
val part = when (hour) { |
| 139 |
in 5..11 -> "Good morning" |
| 140 |
in 12..17 -> "Good afternoon" |
| 141 |
else -> "Good evening" |
| 142 |
} |
| 143 |
return "$part, $name" |
| 144 |
} |
| 145 |
} |
| 146 |
} |