CLI client, SSE
This commit is contained in:
@@ -28,14 +28,11 @@ class AuthProvider private constructor() {
|
||||
|
||||
init {
|
||||
_refresh.value = _refreshAccessor.load()
|
||||
println("Refresh token: ${_refresh.value}")
|
||||
_server.value = _serverAccessor.load()
|
||||
println("Server: ${_server.value}")
|
||||
if(_server.value == null) onLogout()
|
||||
}
|
||||
|
||||
fun setServer(server: String, protocol: String) {
|
||||
println("Setting server to $server, protocol version $protocol")
|
||||
_server.value = server
|
||||
_protocolVersion.value = protocol
|
||||
_serverAccessor.save(server)
|
||||
@@ -45,7 +42,6 @@ class AuthProvider private constructor() {
|
||||
fun onLogin(tokens: TokenResponse) {
|
||||
_lock.withLock {
|
||||
if (server.value == null) throw IllegalStateException("Server is null, cannot log in.")
|
||||
println("Saving new refresh token (${tokens.refresh})")
|
||||
_refresh.value = tokens.refresh
|
||||
_access.value = tokens.access
|
||||
_refreshAccessor.save(tokens.refresh)
|
||||
@@ -55,7 +51,6 @@ class AuthProvider private constructor() {
|
||||
|
||||
fun onRefresh(tokens: TokenResponse) {
|
||||
_lock.withLock {
|
||||
println("Saving new refresh token (${tokens.refresh})")
|
||||
_refresh.value = tokens.refresh
|
||||
_access.value = tokens.access
|
||||
_refreshAccessor.save(tokens.refresh)
|
||||
@@ -64,7 +59,6 @@ class AuthProvider private constructor() {
|
||||
|
||||
fun onLogout() {
|
||||
_lock.withLock {
|
||||
println("Erasing refresh token ${_refresh.value}")
|
||||
_refresh.value = null
|
||||
_access.value = null
|
||||
_refreshAccessor.erase()
|
||||
@@ -73,7 +67,6 @@ class AuthProvider private constructor() {
|
||||
}
|
||||
|
||||
fun asBearer(): BearerTokens? = _lock.withLock {
|
||||
println("Bearer tokens requested (access: ${access.value}, refresh: ${_refresh.value})")
|
||||
BearerTokens(
|
||||
accessToken = _access.value ?: return null,
|
||||
refreshToken = _refresh.value?.toString() ?: return null
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.jaytux.phoebench.clients
|
||||
|
||||
import com.jaytux.phoebench.common.*
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.auth.*
|
||||
import io.ktor.client.plugins.auth.providers.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
@@ -10,8 +11,6 @@ import io.ktor.serialization.kotlinx.json.*
|
||||
import io.ktor.utils.io.*
|
||||
import kotlinx.atomicfu.locks.ReentrantLock
|
||||
import kotlinx.atomicfu.locks.withLock
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class Client private constructor(private val _auth: AuthProvider) {
|
||||
@@ -19,7 +18,6 @@ class Client private constructor(private val _auth: AuthProvider) {
|
||||
install(ContentNegotiation) { json() }
|
||||
}
|
||||
private val _client = platformClient {
|
||||
var tryingRefresh = false
|
||||
install(ContentNegotiation) { json() }
|
||||
|
||||
install(SSE) {
|
||||
@@ -28,38 +26,33 @@ class Client private constructor(private val _auth: AuthProvider) {
|
||||
bufferPolicy = SSEBufferPolicy.LastEvents(5)
|
||||
}
|
||||
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = 10000
|
||||
socketTimeoutMillis = 10000
|
||||
}
|
||||
|
||||
install(Auth) {
|
||||
bearer {
|
||||
cacheTokens = false
|
||||
|
||||
loadTokens {
|
||||
val res = _auth.asBearer()
|
||||
println("Client requested bearer tokens and got $res")
|
||||
res
|
||||
}
|
||||
|
||||
refreshTokens {
|
||||
if (tryingRefresh) {
|
||||
val ref = _auth.refresh.value ?: return@refreshTokens null
|
||||
|
||||
val res = callRoute(_authClient, Routes.Auth.refresh, RefreshRequest(ref), true).foldSuspend({
|
||||
if (it.msg.startsWith(COROUTINE_CANCELLED)) println("Coro calling refresh was cancelled")
|
||||
_auth.onLogout()
|
||||
null
|
||||
} else {
|
||||
tryingRefresh = true
|
||||
val ref = _auth.refresh.value ?: return@refreshTokens null
|
||||
println("Trying to re-authenticate using $ref")
|
||||
|
||||
val res = callRoute(_authClient, Routes.Auth.refresh, RefreshRequest(ref), true).foldSuspend({
|
||||
if (it.msg.startsWith(COROUTINE_CANCELLED)) println("Coro calling refresh was cancelled")
|
||||
_auth.onLogout()
|
||||
null
|
||||
}) {
|
||||
_auth.onRefresh(it)
|
||||
val res = _auth.asBearer()
|
||||
println("Client requested bearer tokens (from refresh) and got $res")
|
||||
res
|
||||
}
|
||||
tryingRefresh = false
|
||||
}) {
|
||||
_auth.onRefresh(it)
|
||||
val res = _auth.asBearer()
|
||||
res
|
||||
}
|
||||
res
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,14 +87,19 @@ class Client private constructor(private val _auth: AuthProvider) {
|
||||
): Either<ErrorResponse, TRes> =
|
||||
callRoute(_client, route, body)
|
||||
|
||||
suspend fun <T> connectSSE(route: String, serializer: KSerializer<T>, onEvent: suspend (T) -> Unit) {
|
||||
println("Client tries to set up SSE to $route")
|
||||
val server = _auth.server.value ?: throw IllegalStateException("No server URL set.")
|
||||
_client.sse(urlString = "$server$route", showCommentEvents = true, showRetryEvents = true) {
|
||||
incoming.collect {
|
||||
println("RECEIVE: $it")
|
||||
if(it.data != null) onEvent(Json.decodeFromString(serializer, it.data!!))
|
||||
}
|
||||
suspend fun <TParams : Any, TEvent : Any> callSSE(
|
||||
route: SSERoute<TParams, TEvent>,
|
||||
params: TParams,
|
||||
handler: suspend (Either<ErrorResponse, TEvent>) -> Unit
|
||||
): Either<ErrorResponse, Unit> {
|
||||
try {
|
||||
println("SSE connection to ${route.path} using server ${_auth.server.value}")
|
||||
val client =
|
||||
IClient.Default(_client, _auth.server.value ?: throw IllegalStateException("No server URL set."))
|
||||
return route.call(client, params, handler)
|
||||
} catch (e: CancellationException) {
|
||||
handler(ErrorResponse("Event stream connected to ${route.path} was cancelled.").error())
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ fun <T> MutableState<T>.immutable(): State<T> = this
|
||||
fun <T> T.ignore() {}
|
||||
|
||||
inline fun <reified R> ViewModel.withScope(crossinline block: suspend () -> R) = viewModelScope.launch {
|
||||
withContext(Dispatchers.Unconfined) { block() }
|
||||
withContext(Dispatchers.Default) { block() }
|
||||
}
|
||||
|
||||
val formatter = LocalDateTime.Format {
|
||||
@@ -83,3 +83,11 @@ fun Float.fmt(): String {
|
||||
val decInt = (decimals * 1000).roundToInt().toFloat() / 1000f
|
||||
return (integer + decInt).toString()
|
||||
}
|
||||
|
||||
inline fun <T, X : Comparable<X>> List<T>.insort(elem: T, crossinline sortBy: (T) -> X): List<T> {
|
||||
val insertionPoint = binarySearchBy(sortBy(elem), selector = sortBy)
|
||||
if(insertionPoint >= 0) return this
|
||||
val index = -insertionPoint - 1
|
||||
|
||||
return toMutableList().apply { add(index, elem) }
|
||||
}
|
||||
@@ -9,11 +9,17 @@ import com.jaytux.phoebench.clients.SnackProvider
|
||||
import com.jaytux.phoebench.clients.SnackProvider.Companion.snackOnError
|
||||
import com.jaytux.phoebench.clients.SnackProvider.Companion.snackOr
|
||||
import com.jaytux.phoebench.clients.immutable
|
||||
import com.jaytux.phoebench.clients.insort
|
||||
import com.jaytux.phoebench.clients.toClipEntry
|
||||
import com.jaytux.phoebench.clients.withScope
|
||||
import com.jaytux.phoebench.common.AdminEvent
|
||||
import com.jaytux.phoebench.common.Either
|
||||
import com.jaytux.phoebench.common.HomeEvent
|
||||
import com.jaytux.phoebench.common.HomeResponse
|
||||
import com.jaytux.phoebench.common.InviteListResponse
|
||||
import com.jaytux.phoebench.common.Routes
|
||||
import com.jaytux.phoebench.common.UserListResponse
|
||||
import com.jaytux.phoebench.common.value
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.serialization.serializer
|
||||
import kotlin.uuid.Uuid
|
||||
@@ -22,7 +28,8 @@ class HomeVM(
|
||||
private val _client: Client = Client.get(),
|
||||
private val _auth: AuthProvider = AuthProvider.get(),
|
||||
private val _snack: SnackProvider = SnackProvider.get(),
|
||||
private val _repo: IHomeRepo = IHomeRepo.default(_client)
|
||||
private val _repo: IHomeRepo = IHomeRepo.default(_client),
|
||||
private val _sseRepo: ISSERepo = ISSERepo.default(_client)
|
||||
) : ViewModel() {
|
||||
private val _username = mutableStateOf<String?>(null)
|
||||
private val _isAdmin = mutableStateOf(false)
|
||||
@@ -33,8 +40,6 @@ class HomeVM(
|
||||
private val _users = mutableStateOf(listOf<UserListResponse.UserData>())
|
||||
private val _invites = mutableStateOf(listOf<InviteListResponse.Invite>())
|
||||
|
||||
private var _tokenWasNull = _auth.refresh.value == null
|
||||
|
||||
val username = _username.immutable()
|
||||
val isAdmin = _isAdmin.immutable()
|
||||
val projectLimit = _projectLimit.immutable()
|
||||
@@ -45,8 +50,15 @@ class HomeVM(
|
||||
val users = _users.immutable()
|
||||
|
||||
private var _listenJob: Job? = null
|
||||
private var _adminJob: Job? = null
|
||||
|
||||
init {
|
||||
_listenJob = withScope {
|
||||
_sseRepo.connectHome {
|
||||
it.snackOr(this::homeEventHandler)
|
||||
}.snackOnError()
|
||||
}
|
||||
|
||||
refresh()
|
||||
}
|
||||
|
||||
@@ -65,24 +77,7 @@ class HomeVM(
|
||||
_publicProjects.value = listOf()
|
||||
}
|
||||
|
||||
fun refreshIfNeeded() {
|
||||
println("Check for refresh: $_tokenWasNull && ${_auth.refresh.value != null}")
|
||||
if(_tokenWasNull && _auth.refresh.value != null) refresh()
|
||||
_tokenWasNull = _auth.refresh.value == null
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
_listenJob = withScope {
|
||||
_client.connectSSE("/rt/project", serializer<HomeResponse.ProjectSummary>()) {
|
||||
if(it.owner.name == _username.value) {
|
||||
if(_ownProjects.value.none { p -> p.id == it.id }) _ownProjects.value += it
|
||||
}
|
||||
if(it.isPublic) {
|
||||
if(_publicProjects.value.none { p -> p.id == it.id }) _publicProjects.value += it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
withScope {
|
||||
resetAdmin()
|
||||
_repo.getHome().snackOr {
|
||||
@@ -90,24 +85,30 @@ class HomeVM(
|
||||
_username.value = it.username
|
||||
_isAdmin.value = it.isAdmin
|
||||
_projectLimit.value = it.projectLimit
|
||||
_ownProjects.value = it.ownProjects
|
||||
_publicProjects.value = it.publicProjects
|
||||
_ownProjects.value = it.ownProjects.sortedBy { p -> p.name }
|
||||
_publicProjects.value = it.publicProjects.sortedBy { p -> p.name }
|
||||
refreshAdmin()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshAdmin() {
|
||||
if(_isAdmin.value) {
|
||||
_adminJob = withScope {
|
||||
_sseRepo.connectAdmin {
|
||||
it.snackOr(this::adminEventHandler)
|
||||
}.snackOnError()
|
||||
}
|
||||
}
|
||||
|
||||
withScope {
|
||||
if(_isAdmin.value) {
|
||||
_repo.inviteList().snackOr { r ->
|
||||
_invites.value = r.uuids.map {
|
||||
it.copy(expires = it.expires)
|
||||
}
|
||||
_invites.value = r.uuids
|
||||
}
|
||||
|
||||
_repo.userList().snackOr { r ->
|
||||
_users.value = r.users
|
||||
_users.value = r.users.sortedBy { it.name }
|
||||
}
|
||||
}
|
||||
else {
|
||||
@@ -117,6 +118,42 @@ class HomeVM(
|
||||
}
|
||||
}
|
||||
|
||||
private fun homeEventHandler(event: HomeEvent) {
|
||||
when (event) {
|
||||
is HomeEvent.Changed -> {
|
||||
arrayOf(_ownProjects, _publicProjects).forEach { state ->
|
||||
state.value = state.value.filter { p -> p.id != event.summary.id }
|
||||
.insort(event.summary, HomeResponse.ProjectSummary::name)
|
||||
}
|
||||
}
|
||||
|
||||
is HomeEvent.Created -> {
|
||||
if (event.summary.owner.name == username.value) {
|
||||
_ownProjects.value = _ownProjects.value.insort(event.summary, HomeResponse.ProjectSummary::name)
|
||||
}
|
||||
if (event.summary.isPublic) {
|
||||
_publicProjects.value = _publicProjects.value.insort(event.summary, HomeResponse.ProjectSummary::name)
|
||||
}
|
||||
}
|
||||
|
||||
is HomeEvent.Deleted -> {
|
||||
arrayOf(_ownProjects, _publicProjects).forEach { state ->
|
||||
state.value = state.value.filter { p -> p.id != event.id }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun adminEventHandler(event: AdminEvent) {
|
||||
when(event) {
|
||||
is AdminEvent.InviteDeleted -> _invites.value = _invites.value.filter { it.code != event.id }
|
||||
is AdminEvent.NewInvite -> _invites.value += event.invite
|
||||
is AdminEvent.NewUser -> _users.value = _users.value.insort(event.user, UserListResponse.UserData::name)
|
||||
is AdminEvent.UserChanged -> _users.value = _users.value.filter { it.id != event.user.id }.insort(event.user, UserListResponse.UserData::name)
|
||||
is AdminEvent.UserDeleted -> _users.value = _users.value.filter { it.id != event.id }
|
||||
}
|
||||
}
|
||||
|
||||
fun mkInvite(clipboard: Clipboard, asAdmin: Boolean) {
|
||||
withScope {
|
||||
_repo.newInvite(asAdmin).snackOr {
|
||||
@@ -171,7 +208,7 @@ class HomeVM(
|
||||
fun mkProject(name: String, isPublic: Boolean) {
|
||||
withScope {
|
||||
_repo.newProject(name, isPublic).snackOr {
|
||||
refresh()
|
||||
if(_listenJob == null) refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-10
@@ -31,8 +31,6 @@ interface IProjectRepo {
|
||||
|
||||
suspend fun newEntry(label: Uuid, timestamp: Instant, warmups: List<Float>, measurements: List<Float>,
|
||||
unit: TimeUnit): Either<ErrorResponse, EntryResponse>
|
||||
suspend fun updateEntry(id: Uuid, label: Uuid? = null, timestamp: Instant? = null, warmups: List<Float>? = null,
|
||||
measurements: List<Float>? = null, unit: TimeUnit? = null): Either<ErrorResponse, Unit>
|
||||
suspend fun deleteEntry(id: Uuid): Either<ErrorResponse, Unit>
|
||||
|
||||
companion object {
|
||||
@@ -60,14 +58,6 @@ interface IProjectRepo {
|
||||
): Either<ErrorResponse, EntryResponse> =
|
||||
_client.callRoute(Routes.Entry.new, EntryRequest(label, timestamp, _projectId, warmups, measurements, unit))
|
||||
|
||||
override suspend fun updateEntry(id: Uuid, label: Uuid?, timestamp: Instant?,
|
||||
warmups: List<Float>?, measurements: List<Float>?,
|
||||
unit: TimeUnit?
|
||||
): Either<ErrorResponse, Unit> =
|
||||
_client.callRoute(Routes.Entry.update,
|
||||
id to PartialEntryRequest(label, timestamp, null, warmups, measurements, unit)
|
||||
).ignoreValue()
|
||||
|
||||
override suspend fun deleteEntry(id: Uuid): Either<ErrorResponse, Unit> =
|
||||
_client.callRoute(Routes.Entry.delete, id).ignoreValue()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.jaytux.phoebench.clients.data
|
||||
|
||||
import com.jaytux.phoebench.clients.Client
|
||||
import com.jaytux.phoebench.common.AdminEvent
|
||||
import com.jaytux.phoebench.common.Either
|
||||
import com.jaytux.phoebench.common.ErrorResponse
|
||||
import com.jaytux.phoebench.common.HomeEvent
|
||||
import com.jaytux.phoebench.common.ProjectEvent
|
||||
import com.jaytux.phoebench.common.Routes
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
interface ISSERepo {
|
||||
suspend fun connectHome(onEvent: suspend (Either<ErrorResponse, HomeEvent>) -> Unit): Either<ErrorResponse, Unit>
|
||||
suspend fun connectAdmin(onEvent: suspend (Either<ErrorResponse, AdminEvent>) -> Unit): Either<ErrorResponse, Unit>
|
||||
suspend fun connectProject(id: Uuid, onEvent: suspend (Either<ErrorResponse, ProjectEvent>) -> Unit): Either<ErrorResponse, Unit>
|
||||
|
||||
companion object {
|
||||
class Default(private val _client: Client) : ISSERepo {
|
||||
override suspend fun connectHome(onEvent: suspend (Either<ErrorResponse, HomeEvent>) -> Unit): Either<ErrorResponse, Unit> =
|
||||
_client.callSSE(Routes.SSE.home, Unit, onEvent)
|
||||
|
||||
override suspend fun connectAdmin(onEvent: suspend (Either<ErrorResponse, AdminEvent>) -> Unit): Either<ErrorResponse, Unit> =
|
||||
_client.callSSE(Routes.SSE.admin, Unit, onEvent)
|
||||
|
||||
override suspend fun connectProject(id: Uuid, onEvent: suspend (Either<ErrorResponse, ProjectEvent>) -> Unit): Either<ErrorResponse, Unit> =
|
||||
_client.callSSE(Routes.SSE.projectSpecific, id, onEvent)
|
||||
}
|
||||
|
||||
fun default(client: Client) = Default(client)
|
||||
}
|
||||
}
|
||||
+37
-9
@@ -6,14 +6,19 @@ import androidx.lifecycle.ViewModel
|
||||
import com.jaytux.phoebench.clients.AuthProvider
|
||||
import com.jaytux.phoebench.clients.Client
|
||||
import com.jaytux.phoebench.clients.SnackProvider
|
||||
import com.jaytux.phoebench.clients.SnackProvider.Companion.snackOnError
|
||||
import com.jaytux.phoebench.clients.SnackProvider.Companion.snackOr
|
||||
import com.jaytux.phoebench.clients.hexString
|
||||
import com.jaytux.phoebench.clients.immutable
|
||||
import com.jaytux.phoebench.clients.insort
|
||||
import com.jaytux.phoebench.clients.systemTz
|
||||
import com.jaytux.phoebench.clients.withScope
|
||||
import com.jaytux.phoebench.common.EntryResponse
|
||||
import com.jaytux.phoebench.common.LabelResponse
|
||||
import com.jaytux.phoebench.common.ProjectEvent
|
||||
import com.jaytux.phoebench.common.TimeUnit
|
||||
import com.jaytux.phoebench.common.foldSuspend
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import kotlin.uuid.Uuid
|
||||
@@ -24,6 +29,7 @@ class ProjectVM(
|
||||
private val _snack: SnackProvider = SnackProvider.get(),
|
||||
private val _client: Client = Client.get(),
|
||||
private val _repo: IProjectRepo = IProjectRepo.default(_client, _id),
|
||||
private val _sseRepo: ISSERepo = ISSERepo.default(_client),
|
||||
private val _forceBack: () -> Unit
|
||||
) : ViewModel() {
|
||||
data class Label(val id: Uuid, val name: String, val colorStr: String, val uiColor: Color = parseColor(colorStr)) {
|
||||
@@ -74,7 +80,16 @@ class ProjectVM(
|
||||
val labels = _labels.immutable()
|
||||
val entries = _entries.immutable()
|
||||
|
||||
private var _job: Job? = null
|
||||
|
||||
init {
|
||||
_job = withScope {
|
||||
_sseRepo.connectProject(_id) {
|
||||
it.snackOr(this::handleProjectEvent)
|
||||
}.snackOnError()
|
||||
|
||||
}
|
||||
|
||||
refresh()
|
||||
}
|
||||
|
||||
@@ -91,6 +106,22 @@ class ProjectVM(
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleProjectEvent(event: ProjectEvent) {
|
||||
when(event) {
|
||||
ProjectEvent.Deleted -> back()
|
||||
is ProjectEvent.EntryDeleted -> _entries.value = _entries.value.filter { it.id != event.id }
|
||||
is ProjectEvent.LabelChanged -> _labels.value += (event.label.id to Label.fromResponse(event.label))
|
||||
is ProjectEvent.LabelDeleted -> _labels.value = _labels.value.filter { it.value.id != event.id }
|
||||
is ProjectEvent.NewEntry -> _entries.value = _entries.value.insort(Entry.fromResponse(event.entry, _labels.value), Entry::timeStamp)
|
||||
is ProjectEvent.NewLabel -> _labels.value += (event.label.id to Label.fromResponse(event.label))
|
||||
is ProjectEvent.Updated -> {
|
||||
_name.value = event.changes.name
|
||||
_owner.value = event.changes.owner.name
|
||||
_public.value = event.changes.isPublic
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun update(name: String?, isPublic: Boolean?) {
|
||||
withScope {
|
||||
_repo.update(name, isPublic).snackOr { refresh() }
|
||||
@@ -99,10 +130,15 @@ class ProjectVM(
|
||||
|
||||
fun delete() {
|
||||
withScope {
|
||||
_repo.delete().snackOr { _forceBack() }
|
||||
_repo.delete().snackOr { back() }
|
||||
}
|
||||
}
|
||||
|
||||
fun back() {
|
||||
_job?.cancel()
|
||||
_forceBack()
|
||||
}
|
||||
|
||||
fun mkLabel(name: String, color: Color) {
|
||||
withScope {
|
||||
_repo.newLabel(name, color).snackOr {
|
||||
@@ -136,14 +172,6 @@ class ProjectVM(
|
||||
}
|
||||
}
|
||||
|
||||
fun updateEntry(id: Uuid, label: Label?, warmups: List<Float>?, measurements: List<Float>?, unit: TimeUnit?) {
|
||||
withScope {
|
||||
_repo.updateEntry(id, label?.id, Clock.System.now(), warmups, measurements, unit).snackOr {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteEntry(id: Uuid) {
|
||||
withScope {
|
||||
_repo.deleteEntry(id).snackOr {
|
||||
|
||||
@@ -275,7 +275,6 @@ fun AuthenticatedRoot() {
|
||||
) { insets ->
|
||||
Surface(Modifier.padding(insets), color = MaterialTheme.colorScheme.surface) {
|
||||
currentProject?.let {
|
||||
BackHandler { leaveProject() }
|
||||
ProjectView(it, ::leaveProject)
|
||||
} ?: run {
|
||||
HomeView { currentProject = it }
|
||||
|
||||
+5
-1
@@ -9,7 +9,9 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.backhandler.BackHandler
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
@@ -54,9 +56,11 @@ import io.github.koalaplot.core.xygraph.rememberGridStyle
|
||||
import kotlin.time.Instant
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun ProjectView(id: Uuid, forceBack: () -> Unit) {
|
||||
val vm = viewModel(key = id.toString()) { ProjectVM(id, _forceBack = forceBack) }
|
||||
BackHandler { vm.back() }
|
||||
|
||||
val name by vm.name
|
||||
val owner by vm.owner
|
||||
@@ -489,7 +493,7 @@ fun ProjectPlotArea(vm: ProjectVM){
|
||||
}
|
||||
else {
|
||||
LazyColumn(Modifier.padding(start = 5.dp)) {
|
||||
items(labels.toList()) { (_, lbl) ->
|
||||
items(labels.toList().sortedBy { it.second.name }) { (_, lbl) ->
|
||||
Box(Modifier.fillMaxWidth().clickable { labelFilter.toggle(lbl) }) {
|
||||
Box {
|
||||
QuickLabel(lbl)
|
||||
|
||||
Reference in New Issue
Block a user