Restructure, beginning of CLI client
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
//import org.gradle.kotlin.dsl.implementation
|
||||
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
|
||||
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
|
||||
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinMultiplatform)
|
||||
alias(libs.plugins.composeMultiplatform)
|
||||
alias(libs.plugins.composeCompiler)
|
||||
alias(libs.plugins.serialization)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
freeCompilerArgs.add("-Xcontext-parameters")
|
||||
optIn.add("kotlin.uuid.ExperimentalUuidApi")
|
||||
}
|
||||
|
||||
jvm("desktop")
|
||||
|
||||
@OptIn(ExperimentalWasmDsl::class)
|
||||
wasmJs {
|
||||
browser {
|
||||
val rootDirPath = project.rootDir.path
|
||||
val projectDirPath = project.projectDir.path
|
||||
commonWebpackConfig {
|
||||
outputFileName = "composeApp.js"
|
||||
devServer = (devServer ?: KotlinWebpackConfig.DevServer()).apply {
|
||||
static(rootDirPath)
|
||||
static(projectDirPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
binaries.executable()
|
||||
}
|
||||
|
||||
applyDefaultHierarchyTemplate()
|
||||
|
||||
sourceSets {
|
||||
val desktopMain by getting
|
||||
|
||||
val nonWeb by creating {
|
||||
dependsOn(commonMain.get())
|
||||
|
||||
dependencies {
|
||||
}
|
||||
}
|
||||
|
||||
desktopMain.dependsOn(nonWeb)
|
||||
|
||||
commonMain.dependencies {
|
||||
implementation(libs.compose.runtime)
|
||||
implementation(libs.compose.foundation)
|
||||
implementation(libs.compose.ui)
|
||||
implementation(libs.compose.components.resources)
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.androidx.lifecycle.viewmodel)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.logging)
|
||||
implementation(libs.ktor.client.auth)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.kotlinx.datetime)
|
||||
implementation(libs.kotlinx.serialization)
|
||||
implementation(libs.ktor.serialization.kotlinx.json)
|
||||
implementation(libs.ktor.client.auth)
|
||||
implementation(libs.ktor.client.logging)
|
||||
implementation(project(":common"))
|
||||
implementation(kotlin("reflect"))
|
||||
implementation(libs.compose.backhandler)
|
||||
implementation(libs.kotlinx.atomic)
|
||||
implementation(libs.lucide)
|
||||
implementation(libs.koala)
|
||||
implementation(libs.kolor)
|
||||
}
|
||||
desktopMain.dependencies {
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation(libs.kotlinx.coroutines.swing)
|
||||
implementation(libs.ktor.client.okhttp)
|
||||
implementation(libs.slf4j.simple)
|
||||
implementation(libs.java.keystore)
|
||||
}
|
||||
wasmJsMain.dependencies {
|
||||
implementation(libs.ktor.client.js)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compose.desktop {
|
||||
application {
|
||||
mainClass = "com.jaytux.phoebench.clients.MainKt"
|
||||
|
||||
nativeDistributions {
|
||||
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
|
||||
packageName = "com.jaytux.phoebench.clients"
|
||||
packageVersion = rootProject.version.toString().split('-')[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.jaytux.phoebench.clients
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import com.jaytux.phoebench.clients.ui.RootView
|
||||
|
||||
@Composable
|
||||
fun App() = MaterialTheme(darkColorScheme()) {
|
||||
val auth = remember { AuthProvider.construct() }
|
||||
val client = remember { Client.construct(auth) }
|
||||
val snacks = remember { SnackProvider.construct() }
|
||||
RootView(auth, client)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.jaytux.phoebench.clients
|
||||
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import com.jaytux.phoebench.common.TokenResponse
|
||||
import io.ktor.client.plugins.auth.providers.BearerTokens
|
||||
import kotlinx.atomicfu.locks.ReentrantLock
|
||||
import kotlinx.atomicfu.locks.withLock
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
class AuthProvider private constructor() {
|
||||
private val _server = mutableStateOf<String?>(null)
|
||||
private val _protocolVersion = mutableStateOf<String?>(null)
|
||||
private val _refresh = mutableStateOf<Uuid?>(null)
|
||||
private val _access = mutableStateOf<String?>(null)
|
||||
private val _session = mutableStateOf(0)
|
||||
private val _lock = ReentrantLock()
|
||||
|
||||
private val _store = persistentStore()
|
||||
private val _refreshAccessor = _store.refreshToken()
|
||||
private val _serverAccessor = _store.server()
|
||||
var access = _access.immutable()
|
||||
|
||||
val server = _server.immutable()
|
||||
val protocolVersion = _protocolVersion.immutable()
|
||||
val refresh = _refresh.immutable()
|
||||
val session = _session.immutable()
|
||||
|
||||
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)
|
||||
onLogout()
|
||||
}
|
||||
|
||||
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)
|
||||
_session.value++
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
fun onLogout() {
|
||||
_lock.withLock {
|
||||
println("Erasing refresh token ${_refresh.value}")
|
||||
_refresh.value = null
|
||||
_access.value = null
|
||||
_refreshAccessor.erase()
|
||||
_session.value++
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
fun clearServer() {
|
||||
_server.value = null
|
||||
_serverAccessor.erase()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private var _instance: AuthProvider? = null
|
||||
private val _lock = ReentrantLock()
|
||||
|
||||
fun construct() = _lock.withLock {
|
||||
if(_instance != null) throw IllegalStateException("AuthProvider is already constructed.")
|
||||
_instance = AuthProvider()
|
||||
_instance!!
|
||||
}
|
||||
|
||||
fun get() = _instance ?: throw IllegalStateException("AuthProvider is not yet constructed.")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.jaytux.phoebench.clients
|
||||
|
||||
import com.jaytux.phoebench.common.*
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.plugins.auth.*
|
||||
import io.ktor.client.plugins.auth.providers.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import io.ktor.utils.io.*
|
||||
import kotlinx.atomicfu.locks.ReentrantLock
|
||||
import kotlinx.atomicfu.locks.withLock
|
||||
|
||||
class Client private constructor(private val _auth: AuthProvider) {
|
||||
private val _authClient = platformClient {
|
||||
install(ContentNegotiation) { json() }
|
||||
}
|
||||
private val _client = platformClient {
|
||||
var tryingRefresh = false
|
||||
install(ContentNegotiation) { json() }
|
||||
install(Auth) {
|
||||
bearer {
|
||||
cacheTokens = false
|
||||
|
||||
loadTokens {
|
||||
val res = _auth.asBearer()
|
||||
println("Client requested bearer tokens and got $res")
|
||||
res
|
||||
}
|
||||
|
||||
refreshTokens {
|
||||
if(tryingRefresh) {
|
||||
_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
|
||||
res
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var _counter = 0
|
||||
|
||||
private suspend fun <TReq: Any, TRes: Any> callRoute(using: HttpClient, route: ApiRoute<TReq, TRes>, body: TReq, wasInternal: Boolean = false): Either<ErrorResponse, TRes> {
|
||||
val ctr = _counter++
|
||||
return try {
|
||||
println("Calling route ${route.pattern} with client $using, request ID=$ctr (internal request: $wasInternal)")
|
||||
val client = IClient.Default(using, _auth.server.value ?: throw IllegalStateException("No server URL set."))
|
||||
val res = route.call(client, body, ctr)
|
||||
res
|
||||
}
|
||||
catch(e: CancellationException) {
|
||||
println("Call to ${route.pattern} [$ctr] was cancelled")
|
||||
ErrorResponse(COROUTINE_CANCELLED).error()
|
||||
}
|
||||
catch(e: Exception) {
|
||||
println("Call to ${route.pattern} [$ctr] ran into an exception")
|
||||
ErrorResponse(e.message ?: "Unknown error.").error()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun <TReq: Any, TRes: Any> callRoute(route: ApiRoute<TReq, TRes>, body: TReq): Either<ErrorResponse, TRes> =
|
||||
callRoute(_client, route, body)
|
||||
|
||||
companion object {
|
||||
private val _sync = ReentrantLock()
|
||||
private var _instance: Client? = null
|
||||
const val COROUTINE_CANCELLED = "!!CORO CANCELLATION!!"
|
||||
|
||||
fun construct(auth: AuthProvider): Client {
|
||||
_sync.withLock {
|
||||
if(_instance == null) {
|
||||
_instance = Client(auth)
|
||||
return _instance!!
|
||||
}
|
||||
else throw IllegalStateException("Client already constructed")
|
||||
}
|
||||
}
|
||||
|
||||
fun get() = _instance ?: throw IllegalStateException("Client not constructed yet")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.jaytux.phoebench.clients
|
||||
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.HttpClientConfig
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
interface IStore {
|
||||
interface IStoredProperty<T> {
|
||||
fun load(): T?
|
||||
fun save(value: T)
|
||||
fun erase()
|
||||
}
|
||||
|
||||
fun refreshToken(): IStoredProperty<Uuid>
|
||||
fun server(): IStoredProperty<String>
|
||||
}
|
||||
|
||||
expect fun persistentStore(): IStore
|
||||
|
||||
expect fun platformClient(builder: HttpClientConfig<*>.() -> Unit): HttpClient
|
||||
|
||||
expect suspend fun String.toClipEntry(): ClipEntry
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.jaytux.phoebench.clients
|
||||
|
||||
import com.jaytux.phoebench.common.Either
|
||||
import com.jaytux.phoebench.common.ErrorResponse
|
||||
import com.jaytux.phoebench.common.fold
|
||||
import com.jaytux.phoebench.common.foldSuspend
|
||||
import kotlinx.atomicfu.locks.ReentrantLock
|
||||
import kotlinx.atomicfu.locks.withLock
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
|
||||
class SnackProvider private constructor() {
|
||||
private val _snacks = Channel<String>(Channel.BUFFERED, BufferOverflow.DROP_OLDEST)
|
||||
val snacks = _snacks.receiveAsFlow()
|
||||
|
||||
fun send(snack: String) {
|
||||
// println("Received snack message '$snack'")
|
||||
// Exception().printStackTrace()
|
||||
if(snack.startsWith(Client.COROUTINE_CANCELLED)) _snacks.trySend("Request was cancelled")
|
||||
else _snacks.trySend(snack)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private var _instance: SnackProvider? = null
|
||||
private val _lock = ReentrantLock()
|
||||
|
||||
fun construct(): SnackProvider {
|
||||
_lock.withLock {
|
||||
if(_instance == null) {
|
||||
_instance = SnackProvider()
|
||||
return _instance!!
|
||||
}
|
||||
else throw IllegalStateException("SnackProvider already constructed.")
|
||||
}
|
||||
}
|
||||
|
||||
fun get() = _instance ?: throw IllegalStateException("SnackProvider not constructed.")
|
||||
|
||||
inline fun <reified T> Either<ErrorResponse, T>.snackOnError() =
|
||||
fold({ get().send(it.msg) }) {}
|
||||
|
||||
inline fun <reified T> Either<ErrorResponse, T>.snackOr(process: (T) -> Unit) =
|
||||
fold({ get().send(it.msg) }) { process(it) }
|
||||
suspend inline fun <reified T> Either<ErrorResponse, T>.snackOrSuspend(process: suspend (T) -> Unit) =
|
||||
foldSuspend({ get().send(it.msg) }) { process(it) }
|
||||
|
||||
suspend inline fun <reified T> Either<ErrorResponse, T>.snackMaybeSuspend(): T? =
|
||||
foldSuspend({ get().send(it.msg); null }) { it }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.jaytux.phoebench.clients
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.jaytux.phoebench.common.Either
|
||||
import com.jaytux.phoebench.common.map
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.format
|
||||
import kotlinx.datetime.format.MonthNames
|
||||
import kotlinx.datetime.format.Padding
|
||||
import kotlinx.datetime.format.char
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.random.Random
|
||||
import kotlin.random.nextInt
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
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() }
|
||||
}
|
||||
|
||||
val formatter = LocalDateTime.Format {
|
||||
val noPad = Padding.NONE
|
||||
day(noPad); char(' '); monthName(MonthNames.ENGLISH_ABBREVIATED); char(' '); year(noPad); char(' ')
|
||||
hour(); char(':'); minute(); char(':'); second()
|
||||
}
|
||||
|
||||
fun Instant.fmt(): String = this.toLocalDateTime(systemTz).format(formatter)
|
||||
|
||||
val systemTz = TimeZone.currentSystemDefault()
|
||||
|
||||
fun Instant.isPast() = compareTo(Clock.System.now()) <= 0
|
||||
|
||||
infix fun <T> T.nonEq(other: T) = if(this == other) null else this
|
||||
|
||||
inline fun <reified E, reified T> Either<E, T>.ignoreValue(): Either<E, Unit> = map {}
|
||||
|
||||
private const val HEX_ARRAY = "0123456789abcdef"
|
||||
|
||||
fun Color.hexString() = "#${(this.toArgb() and 0xFFFFFF).toHexString(HexFormat.UpperCase).takeLast(6)}"
|
||||
|
||||
fun randomColor() = Color(
|
||||
red = Random.nextInt(0..255),
|
||||
green = Random.nextInt(0..255),
|
||||
blue = Random.nextInt(0..255)
|
||||
)
|
||||
|
||||
fun Color.darken(factor: Float): Color {
|
||||
val inv = 1.0f - factor
|
||||
return copy(red = red * inv, green = green * inv, blue = blue * inv)
|
||||
}
|
||||
|
||||
fun dualLerp(min: Instant, max: Instant, low: Float, high: Float): Pair<Instant, Instant> {
|
||||
if(max == min || (low - high).absoluteValue < 1e-6f) return min to max
|
||||
val delta = if(max > min) (max - min) else (min - max)
|
||||
|
||||
val lowTime = min + delta * low.toDouble()
|
||||
val highTime = min + delta * high.toDouble()
|
||||
|
||||
return if(lowTime < highTime) (lowTime to highTime) else (highTime to lowTime)
|
||||
}
|
||||
|
||||
fun Pair<Instant, Instant>.fmtRange() = "Between ${first.fmt()} and ${second.fmt()}"
|
||||
|
||||
infix fun Instant.inRange(range: Pair<Instant, Instant>) = range.first <= this && this <= range.second
|
||||
|
||||
fun Float.fmt(): String {
|
||||
val integer = toInt()
|
||||
val decimals = this - integer
|
||||
val decInt = (decimals * 1000).roundToInt().toFloat() / 1000f
|
||||
return (integer + decInt).toString()
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.jaytux.phoebench.clients.data
|
||||
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.platform.Clipboard
|
||||
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.immutable
|
||||
import com.jaytux.phoebench.clients.toClipEntry
|
||||
import com.jaytux.phoebench.clients.withScope
|
||||
import com.jaytux.phoebench.common.HomeResponse
|
||||
import com.jaytux.phoebench.common.InviteListResponse
|
||||
import com.jaytux.phoebench.common.UserListResponse
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
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)
|
||||
) : ViewModel() {
|
||||
private val _username = mutableStateOf<String?>(null)
|
||||
private val _isAdmin = mutableStateOf(false)
|
||||
private val _projectLimit = mutableStateOf(0)
|
||||
private val _ownProjects = mutableStateOf(listOf<HomeResponse.ProjectSummary>())
|
||||
private val _publicProjects = mutableStateOf(listOf<HomeResponse.ProjectSummary>())
|
||||
|
||||
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()
|
||||
val ownProjects = _ownProjects.immutable()
|
||||
val publicProjects = _publicProjects.immutable()
|
||||
|
||||
val invites = _invites.immutable()
|
||||
val users = _users.immutable()
|
||||
|
||||
init {
|
||||
refresh()
|
||||
}
|
||||
|
||||
private fun resetAdmin() {
|
||||
_users.value = listOf()
|
||||
_invites.value = listOf()
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
resetAdmin()
|
||||
_username.value = null
|
||||
_isAdmin.value = false
|
||||
_projectLimit.value = 0
|
||||
_ownProjects.value = listOf()
|
||||
_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() {
|
||||
withScope {
|
||||
resetAdmin()
|
||||
_repo.getHome().snackOr {
|
||||
println("Received home response $it")
|
||||
_username.value = it.username
|
||||
_isAdmin.value = it.isAdmin
|
||||
_projectLimit.value = it.projectLimit
|
||||
_ownProjects.value = it.ownProjects
|
||||
_publicProjects.value = it.publicProjects
|
||||
refreshAdmin()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshAdmin() {
|
||||
withScope {
|
||||
if(_isAdmin.value) {
|
||||
_repo.inviteList().snackOr { r ->
|
||||
_invites.value = r.uuids.map {
|
||||
it.copy(expires = it.expires)
|
||||
}
|
||||
}
|
||||
|
||||
_repo.userList().snackOr { r ->
|
||||
_users.value = r.users
|
||||
}
|
||||
}
|
||||
else {
|
||||
_invites.value = listOf()
|
||||
_users.value = listOf()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun mkInvite(clipboard: Clipboard, asAdmin: Boolean) {
|
||||
withScope {
|
||||
_repo.newInvite(asAdmin).snackOr {
|
||||
refreshAdmin()
|
||||
clipboard.setClipEntry(it.uuid.toString().toClipEntry())
|
||||
_snack.send("Copied new invite to clipboard (${it.uuid})")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteInvite(id: Uuid) {
|
||||
withScope {
|
||||
_repo.deleteInvite(id).snackOr {
|
||||
refreshAdmin()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteUser(id: Uuid) {
|
||||
withScope {
|
||||
_repo.deleteUser(id).snackOr {
|
||||
refreshAdmin()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateUser(id: Uuid, isAdmin: Boolean?, projectLimit: Int?) {
|
||||
withScope {
|
||||
_repo.updateUser(id, isAdmin, projectLimit).snackOr {
|
||||
refreshAdmin()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun logout(everywhere: Boolean) {
|
||||
withScope {
|
||||
if (everywhere) {
|
||||
_repo.logoutEverywhere().snackOnError()
|
||||
}
|
||||
else {
|
||||
_auth.refresh.value?.let {
|
||||
_repo.logout(it)
|
||||
} ?: {
|
||||
_snack.send("No refresh token.")
|
||||
}
|
||||
}
|
||||
reset()
|
||||
_auth.onLogout()
|
||||
}
|
||||
}
|
||||
|
||||
fun mkProject(name: String, isPublic: Boolean) {
|
||||
withScope {
|
||||
_repo.newProject(name, isPublic).snackOr {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.jaytux.phoebench.clients.data
|
||||
|
||||
import com.jaytux.phoebench.clients.Client
|
||||
import com.jaytux.phoebench.clients.ignoreValue
|
||||
import com.jaytux.phoebench.common.Either
|
||||
import com.jaytux.phoebench.common.EmptyRequest
|
||||
import com.jaytux.phoebench.common.ErrorResponse
|
||||
import com.jaytux.phoebench.common.HomeResponse
|
||||
import com.jaytux.phoebench.common.InviteListResponse
|
||||
import com.jaytux.phoebench.common.InviteRequest
|
||||
import com.jaytux.phoebench.common.LogoutRequest
|
||||
import com.jaytux.phoebench.common.ProjectRequest
|
||||
import com.jaytux.phoebench.common.Routes
|
||||
import com.jaytux.phoebench.common.UserListResponse
|
||||
import com.jaytux.phoebench.common.UserUpdateRequest
|
||||
import com.jaytux.phoebench.common.UuidResponse
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
interface IHomeRepo {
|
||||
suspend fun getHome(): Either<ErrorResponse, HomeResponse>
|
||||
suspend fun logout(refresh: Uuid): Either<ErrorResponse, Unit>
|
||||
suspend fun logoutEverywhere(): Either<ErrorResponse, Unit>
|
||||
|
||||
suspend fun inviteList(): Either<ErrorResponse, InviteListResponse>
|
||||
suspend fun newInvite(isAdmin: Boolean): Either<ErrorResponse, UuidResponse>
|
||||
suspend fun deleteInvite(id: Uuid): Either<ErrorResponse, Unit>
|
||||
|
||||
suspend fun userList(): Either<ErrorResponse, UserListResponse>
|
||||
suspend fun updateUser(id: Uuid, isAdmin: Boolean? = null, projectLimit: Int? = null): Either<ErrorResponse, Unit>
|
||||
suspend fun deleteUser(id: Uuid): Either<ErrorResponse, Unit>
|
||||
|
||||
suspend fun newProject(name: String, isPublic: Boolean): Either<ErrorResponse, Unit>
|
||||
|
||||
companion object {
|
||||
class Default(private val _client: Client) : IHomeRepo {
|
||||
override suspend fun getHome(): Either<ErrorResponse, HomeResponse> =
|
||||
_client.callRoute(Routes.home, EmptyRequest())
|
||||
|
||||
override suspend fun logout(refresh: Uuid): Either<ErrorResponse, Unit> =
|
||||
_client.callRoute(Routes.Auth.logout, LogoutRequest(refresh)).ignoreValue()
|
||||
|
||||
override suspend fun logoutEverywhere(): Either<ErrorResponse, Unit> =
|
||||
_client.callRoute(Routes.Auth.logoutEverywhere, EmptyRequest()).ignoreValue()
|
||||
|
||||
override suspend fun inviteList(): Either<ErrorResponse, InviteListResponse> =
|
||||
_client.callRoute(Routes.Auth.Invite.list, EmptyRequest())
|
||||
|
||||
override suspend fun newInvite(isAdmin: Boolean): Either<ErrorResponse, UuidResponse> =
|
||||
_client.callRoute(Routes.Auth.Invite.new, InviteRequest(isAdmin))
|
||||
|
||||
override suspend fun deleteInvite(id: Uuid): Either<ErrorResponse, Unit> =
|
||||
_client.callRoute(Routes.Auth.Invite.delete, id).ignoreValue()
|
||||
|
||||
override suspend fun userList(): Either<ErrorResponse, UserListResponse> =
|
||||
_client.callRoute(Routes.Auth.User.list, EmptyRequest())
|
||||
|
||||
override suspend fun updateUser(id: Uuid, isAdmin: Boolean?, projectLimit: Int?): Either<ErrorResponse, Unit> =
|
||||
_client.callRoute(Routes.Auth.User.update, id to UserUpdateRequest(projectLimit, isAdmin)).ignoreValue()
|
||||
|
||||
override suspend fun deleteUser(id: Uuid): Either<ErrorResponse, Unit> =
|
||||
_client.callRoute(Routes.Auth.User.delete, id).ignoreValue()
|
||||
|
||||
override suspend fun newProject(name: String, isPublic: Boolean): Either<ErrorResponse, Unit> =
|
||||
_client.callRoute(Routes.Project.new, ProjectRequest(name, isPublic)).ignoreValue()
|
||||
}
|
||||
|
||||
fun default(client: Client) = Default(client)
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package com.jaytux.phoebench.clients.data
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.jaytux.phoebench.clients.Client
|
||||
import com.jaytux.phoebench.clients.hexString
|
||||
import com.jaytux.phoebench.clients.ignoreValue
|
||||
import com.jaytux.phoebench.common.Either
|
||||
import com.jaytux.phoebench.common.EntryRequest
|
||||
import com.jaytux.phoebench.common.EntryResponse
|
||||
import com.jaytux.phoebench.common.ErrorResponse
|
||||
import com.jaytux.phoebench.common.LabelRequest
|
||||
import com.jaytux.phoebench.common.LabelResponse
|
||||
import com.jaytux.phoebench.common.PartialEntryRequest
|
||||
import com.jaytux.phoebench.common.PartialLabelRequest
|
||||
import com.jaytux.phoebench.common.PartialProjectRequest
|
||||
import com.jaytux.phoebench.common.ProjectResponse
|
||||
import com.jaytux.phoebench.common.Routes
|
||||
import com.jaytux.phoebench.common.TimeUnit
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlin.time.Instant
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
interface IProjectRepo {
|
||||
suspend fun get(): Either<ErrorResponse, ProjectResponse>
|
||||
suspend fun update(name: String? = null, isPublic: Boolean? = null): Either<ErrorResponse, Unit>
|
||||
suspend fun delete(): Either<ErrorResponse, Unit>
|
||||
|
||||
suspend fun newLabel(name: String, color: Color): Either<ErrorResponse, LabelResponse>
|
||||
suspend fun updateLabel(id: Uuid, name: String? = null, color: Color? = null): Either<ErrorResponse, Unit>
|
||||
suspend fun deleteLabel(id: Uuid): Either<ErrorResponse, Unit>
|
||||
|
||||
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 {
|
||||
class Default(private val _client: Client, private val _projectId: Uuid) : IProjectRepo {
|
||||
override suspend fun get(): Either<ErrorResponse, ProjectResponse> =
|
||||
_client.callRoute(Routes.Project.get, _projectId)
|
||||
|
||||
override suspend fun update(name: String?, isPublic: Boolean?): Either<ErrorResponse, Unit> =
|
||||
_client.callRoute(Routes.Project.update, _projectId to PartialProjectRequest(name, isPublic)).ignoreValue()
|
||||
|
||||
override suspend fun delete(): Either<ErrorResponse, Unit> =
|
||||
_client.callRoute(Routes.Project.delete, _projectId).ignoreValue()
|
||||
|
||||
override suspend fun newLabel(name: String, color: Color): Either<ErrorResponse, LabelResponse> =
|
||||
_client.callRoute(Routes.Label.new, LabelRequest(name, color.hexString(), _projectId))
|
||||
|
||||
override suspend fun updateLabel(id: Uuid, name: String?, color: Color?): Either<ErrorResponse, Unit> =
|
||||
_client.callRoute(Routes.Label.update, id to PartialLabelRequest(name, color?.hexString())).ignoreValue()
|
||||
|
||||
override suspend fun deleteLabel(id: Uuid): Either<ErrorResponse, Unit> =
|
||||
_client.callRoute(Routes.Label.delete, id).ignoreValue()
|
||||
|
||||
override suspend fun newEntry(label: Uuid, timestamp: Instant, warmups: List<Float>,
|
||||
measurements: List<Float>, unit: TimeUnit
|
||||
): 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()
|
||||
}
|
||||
|
||||
fun default(client: Client, projectId: Uuid) = Default(client, projectId)
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package com.jaytux.phoebench.clients.data
|
||||
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import com.jaytux.phoebench.clients.immutable
|
||||
|
||||
class MutableStateSet<T> {
|
||||
private val _internal = mutableStateMapOf<T, Unit>()
|
||||
private val _revision = mutableStateOf(0)
|
||||
val revision = _revision.immutable()
|
||||
|
||||
fun add(element: T) {
|
||||
_internal[element] = Unit
|
||||
_revision.value++
|
||||
}
|
||||
|
||||
fun remove(element: T) {
|
||||
_internal.remove(element)
|
||||
_revision.value++
|
||||
}
|
||||
|
||||
fun toggle(element: T) {
|
||||
if(element in this) remove(element)
|
||||
else add(element)
|
||||
_revision.value++
|
||||
}
|
||||
|
||||
fun addAll(elements: Collection<T>) {
|
||||
_internal.putAll(elements.map { it to Unit })
|
||||
_revision.value++
|
||||
}
|
||||
|
||||
fun removeAll(elements: Collection<T>) {
|
||||
elements.forEach { _internal.remove(it) }
|
||||
_revision.value++
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
_internal.clear()
|
||||
_revision.value++
|
||||
}
|
||||
|
||||
val size: Int
|
||||
get() = _internal.size
|
||||
|
||||
fun isEmpty(): Boolean = _internal.isEmpty()
|
||||
|
||||
operator fun contains(element: T): Boolean = element in _internal
|
||||
|
||||
fun toSet(): Set<T> = _internal.keys
|
||||
}
|
||||
|
||||
fun <T> mutableStateSetOf(vararg elements: T): MutableStateSet<T> = mutableStateSetFrom(elements.toList())
|
||||
|
||||
fun <T> mutableStateSetFrom(coll: Collection<T>): MutableStateSet<T> {
|
||||
val res = MutableStateSet<T>()
|
||||
res.addAll(coll)
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.jaytux.phoebench.clients.data
|
||||
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.snackOr
|
||||
import com.jaytux.phoebench.clients.hexString
|
||||
import com.jaytux.phoebench.clients.immutable
|
||||
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.TimeUnit
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
class ProjectVM(
|
||||
private val _id: Uuid,
|
||||
private val _auth: AuthProvider = AuthProvider.get(),
|
||||
private val _snack: SnackProvider = SnackProvider.get(),
|
||||
private val _client: Client = Client.get(),
|
||||
private val _repo: IProjectRepo = IProjectRepo.default(_client, _id),
|
||||
private val _forceBack: () -> Unit
|
||||
) : ViewModel() {
|
||||
data class Label(val id: Uuid, val name: String, val colorStr: String, val uiColor: Color = parseColor(colorStr)) {
|
||||
companion object {
|
||||
private val _snack by lazy { SnackProvider.get() }
|
||||
private val _errColor = Color(red = 252, green = 20, blue = 182)
|
||||
|
||||
private fun errorColor(str: String): Color {
|
||||
_snack.send("Project uses invalid color: $str")
|
||||
return _errColor
|
||||
}
|
||||
|
||||
private fun parseColor(str: String): Color {
|
||||
if(str[0] != '#') return errorColor(str)
|
||||
val r = str.substring(1, 3).toIntOrNull(16) ?: return errorColor(str)
|
||||
val g = str.substring(3, 5).toIntOrNull(16) ?: return errorColor(str)
|
||||
val b = str.substring(5, 7).toIntOrNull(16) ?: return errorColor(str)
|
||||
return Color(red = r, green = g, blue = b)
|
||||
}
|
||||
|
||||
fun fromResponse(it: LabelResponse) = Label(it.id, it.name, it.color)
|
||||
|
||||
val invalid = Label(Uuid.fromLongs(0L, 0L), "<invalid>", _errColor.hexString(), _errColor)
|
||||
}
|
||||
}
|
||||
|
||||
data class Entry(val id: Uuid, val label: Label, val timeStamp: Instant, val warmups: List<Float>,
|
||||
val measurements: List<Float>, val nativeUnit: TimeUnit) {
|
||||
companion object {
|
||||
fun fromResponse(it: EntryResponse, map: Map<Uuid, Label>) = Entry(
|
||||
it.id, map[it.labelId] ?: Label.invalid, it.timestamp,
|
||||
it.warmups, it.measurements, it.unit
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val _name = mutableStateOf<String?>(null)
|
||||
private val _owner = mutableStateOf<String?>(null)
|
||||
private val _public = mutableStateOf(false)
|
||||
private val _editable = mutableStateOf(false)
|
||||
private val _labels = mutableStateOf(mapOf<Uuid, Label>())
|
||||
private val _entries = mutableStateOf(listOf<Entry>())
|
||||
|
||||
val name = _name.immutable()
|
||||
val owner = _owner.immutable()
|
||||
val public = _public.immutable()
|
||||
val editable = _editable.immutable()
|
||||
val labels = _labels.immutable()
|
||||
val entries = _entries.immutable()
|
||||
|
||||
init {
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
withScope {
|
||||
_repo.get().snackOr {
|
||||
_name.value = it.name
|
||||
_owner.value = it.owner.name
|
||||
_public.value = it.isPublic
|
||||
_editable.value = it.isEditable
|
||||
_labels.value = it.usedLabels.associate { l -> l.id to Label.fromResponse(l) }
|
||||
_entries.value = it.entries.map { e -> Entry.fromResponse(e, _labels.value) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun update(name: String?, isPublic: Boolean?) {
|
||||
withScope {
|
||||
_repo.update(name, isPublic).snackOr { refresh() }
|
||||
}
|
||||
}
|
||||
|
||||
fun delete() {
|
||||
withScope {
|
||||
_repo.delete().snackOr { _forceBack() }
|
||||
}
|
||||
}
|
||||
|
||||
fun mkLabel(name: String, color: Color) {
|
||||
withScope {
|
||||
_repo.newLabel(name, color).snackOr {
|
||||
_labels.value += it.id to Label(it.id, it.name, it.color)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateLabel(id: Uuid, name: String?, color: Color?) {
|
||||
withScope {
|
||||
val old = _labels.value[id] ?: return@withScope
|
||||
_repo.updateLabel(id, name, color).snackOr {
|
||||
_labels.value += id to Label(id, name ?: old.name, color?.hexString() ?: old.colorStr, color ?: old.uiColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteLabel(id: Uuid) {
|
||||
withScope {
|
||||
_repo.deleteLabel(id).snackOr {
|
||||
_labels.value -= id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun mkEntry(label: Label, warmups: List<Float>, measurements: List<Float>, unit: TimeUnit) {
|
||||
withScope {
|
||||
_repo.newEntry(label.id, Clock.System.now(), warmups, measurements, unit).snackOr {
|
||||
_entries.value += Entry.fromResponse(it, _labels.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
_entries.value = _entries.value.filter { it.id != id }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.jaytux.phoebench.clients.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val linkColor = Color(0xFF64B5F6)
|
||||
@@ -0,0 +1,694 @@
|
||||
package com.jaytux.phoebench.clients.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.PrimaryTabRow
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SecondaryScrollableTabRow
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
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.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.composables.icons.lucide.ChevronLeft
|
||||
import com.composables.icons.lucide.Delete
|
||||
import com.composables.icons.lucide.LogOut
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Pencil
|
||||
import com.composables.icons.lucide.Plus
|
||||
import com.composables.icons.lucide.ShieldPlus
|
||||
import com.composables.icons.lucide.Trash
|
||||
import com.composables.icons.lucide.X
|
||||
import com.jaytux.phoebench.clients.AuthProvider
|
||||
import com.jaytux.phoebench.clients.Client
|
||||
import com.jaytux.phoebench.clients.SnackProvider
|
||||
import com.jaytux.phoebench.clients.data.HomeVM
|
||||
import com.jaytux.phoebench.clients.data.mutableStateSetOf
|
||||
import com.jaytux.phoebench.clients.fmt
|
||||
import com.jaytux.phoebench.clients.isPast
|
||||
import com.jaytux.phoebench.clients.nonEq
|
||||
import com.jaytux.phoebench.clients.theme.linkColor
|
||||
import com.jaytux.phoebench.common.EmptyRequest
|
||||
import com.jaytux.phoebench.common.HomeResponse
|
||||
import com.jaytux.phoebench.common.LoginRequest
|
||||
import com.jaytux.phoebench.common.NamedID
|
||||
import com.jaytux.phoebench.common.ProtocolVersion
|
||||
import com.jaytux.phoebench.common.RefreshRequest
|
||||
import com.jaytux.phoebench.common.Routes
|
||||
import com.jaytux.phoebench.common.SignupRequest
|
||||
import com.jaytux.phoebench.common.UserListResponse
|
||||
import com.jaytux.phoebench.common.fold
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
@Composable
|
||||
fun RootView(auth: AuthProvider, client: Client) = Surface(tonalElevation = 1.dp) {
|
||||
var isSignup by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
val server by auth.server
|
||||
val refresh by auth.refresh
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
suspend fun onServerSelect(server: String): Boolean {
|
||||
if(!server.startsWith("https://") && !server.startsWith("http://0.0.0.0") && !server.startsWith("http://localhost")) {
|
||||
error = "Server connection might be insecure. PhoeBench is only supported over https."
|
||||
return false
|
||||
}
|
||||
|
||||
auth.setServer(server, ProtocolVersion.VERSION)
|
||||
return client.callRoute(Routes.handshake, EmptyRequest()).fold({
|
||||
auth.clearServer()
|
||||
error = it.msg
|
||||
false
|
||||
}) {
|
||||
if(it.version != ProtocolVersion.VERSION) {
|
||||
error = "Mismatching protocol version."
|
||||
false
|
||||
}
|
||||
else {
|
||||
auth.setServer(server, it.version)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onLogin(server: String, user: String, pass: String) {
|
||||
scope.launch {
|
||||
if(!onServerSelect(server)) return@launch
|
||||
client.callRoute(Routes.Auth.login, LoginRequest(user, pass)).fold({
|
||||
error = it.msg
|
||||
}) {
|
||||
auth.onLogin(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onSignup(server: String, invite: String, user: String, pass: String) {
|
||||
scope.launch {
|
||||
if(!onServerSelect(server)) return@launch
|
||||
val inviteUuid = Uuid.parseOrNull(invite) ?: run {
|
||||
error = "Invalid invite code format."
|
||||
return@launch
|
||||
}
|
||||
client.callRoute(Routes.Auth.signup, SignupRequest(inviteUuid, user, pass)).fold({
|
||||
error = it.msg
|
||||
}) {
|
||||
auth.onLogin(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onSwitch() {
|
||||
isSignup = !isSignup
|
||||
error = null
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Surface(Modifier.align(Alignment.Center).padding(5.dp), shape = MaterialTheme.shapes.medium, tonalElevation = 5.dp) {
|
||||
refresh?.let {
|
||||
AuthenticatedRoot()
|
||||
} ?: run {
|
||||
if(isSignup) SignupView(server, error, ::onSwitch, ::onSignup)
|
||||
else LoginView(server, error, ::onSwitch, ::onLogin)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoginView(server: String?, error: String?, onSwitchSignup: () -> Unit, onLogin: (server: String, user: String, pass: String) -> Unit) {
|
||||
var serverUrl by remember { mutableStateOf(server ?: "https://") }
|
||||
var username by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
|
||||
val enabled = ((serverUrl.startsWith("https://") || serverUrl.startsWith("http://0.0.0.0")) || serverUrl.startsWith("http://localhost")) && username.isNotBlank() && password.isNotBlank()
|
||||
|
||||
Column(Modifier.padding(5.dp).widthIn(min = 250.dp).width(IntrinsicSize.Min), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Log in", style = MaterialTheme.typography.headlineMedium)
|
||||
|
||||
OutlinedTextField(serverUrl, { serverUrl = it }, Modifier.fillMaxWidth(), label = { Text("Server URL") }, singleLine = true)
|
||||
|
||||
OutlinedTextField(username, { username = it }, Modifier.fillMaxWidth(), label = { Text("Username") }, singleLine = true)
|
||||
OutlinedTextField(password, { password = it }, Modifier.fillMaxWidth(), label = { Text("Password") }, visualTransformation = PasswordVisualTransformation(), singleLine = true)
|
||||
|
||||
Button({ onLogin(serverUrl.trim(), username.trim(), password) }, Modifier.fillMaxWidth(), enabled = enabled) {
|
||||
Text("Log in")
|
||||
}
|
||||
|
||||
error?.let {
|
||||
Text(it, fontStyle = FontStyle.Italic, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
|
||||
Row {
|
||||
Text("No account yet? ")
|
||||
Text("Sign up", Modifier.clickable { onSwitchSignup() }, color = linkColor, textDecoration = TextDecoration.Underline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SignupView(server: String?, error: String?, onSwitchLogin: () -> Unit, onSignup: (server: String, invite: String, user: String, pass: String) -> Unit) {
|
||||
var serverUrl by remember { mutableStateOf(server ?: "https://") }
|
||||
var inviteCode by remember { mutableStateOf("") }
|
||||
var username by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
|
||||
val enabled = ((serverUrl.startsWith("https://") || serverUrl.startsWith("http://0.0.0.0")) || serverUrl.startsWith("http://localhost")) && inviteCode.isNotBlank() && username.isNotBlank() && password.isNotBlank()
|
||||
|
||||
Column(Modifier.padding(5.dp).widthIn(min = 250.dp).width(IntrinsicSize.Min), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Sign up", style = MaterialTheme.typography.headlineMedium)
|
||||
|
||||
OutlinedTextField(serverUrl, { serverUrl = it }, Modifier.fillMaxWidth(), label = { Text("Server URL") }, singleLine = true)
|
||||
|
||||
OutlinedTextField(inviteCode, { inviteCode = it }, Modifier.fillMaxWidth(), label = { Text("Invite code") }, singleLine = true)
|
||||
OutlinedTextField(username, { username = it }, Modifier.fillMaxWidth(), label = { Text("Username") }, singleLine = true)
|
||||
OutlinedTextField(password, { password = it }, Modifier.fillMaxWidth(), label = { Text("Password") }, visualTransformation = PasswordVisualTransformation(), singleLine = true)
|
||||
|
||||
Button({ onSignup(serverUrl.trim(), inviteCode.trim(), username.trim(), password) }, Modifier.fillMaxWidth(), enabled = enabled) {
|
||||
Text("Sign up")
|
||||
}
|
||||
|
||||
error?.let {
|
||||
Text(it, fontStyle = FontStyle.Italic, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
|
||||
Row {
|
||||
Text("Already have an account? ")
|
||||
Text("Log in", Modifier.clickable { onSwitchLogin() }, color = linkColor, textDecoration = TextDecoration.Underline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun AuthenticatedRoot() {
|
||||
val snacks = SnackProvider.get()
|
||||
val state = remember { SnackbarHostState() }
|
||||
val vm = viewModel { HomeVM() }
|
||||
var loggingOut by remember { mutableStateOf(false) }
|
||||
var currentProject by remember { mutableStateOf<Uuid?>(null) }
|
||||
|
||||
LaunchedEffect(snacks) {
|
||||
snacks.snacks.collect { state.showSnackbar(it) }
|
||||
}
|
||||
|
||||
fun leaveProject() {
|
||||
currentProject = null
|
||||
vm.refresh()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("PhoeBench", style = MaterialTheme.typography.headlineLarge) },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
),
|
||||
actions = {
|
||||
IconButton({ loggingOut = true }) {
|
||||
Icon(Lucide.LogOut, "Log out")
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
if(currentProject != null) {
|
||||
IconButton(::leaveProject) {
|
||||
Icon(Lucide.ChevronLeft, "Back")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
snackbarHost = {
|
||||
SnackbarHost(state)
|
||||
}
|
||||
) { insets ->
|
||||
Surface(Modifier.padding(insets), color = MaterialTheme.colorScheme.surface) {
|
||||
currentProject?.let {
|
||||
BackHandler { leaveProject() }
|
||||
ProjectView(it, ::leaveProject)
|
||||
} ?: run {
|
||||
HomeView { currentProject = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(loggingOut) ConfirmLogoutDialog({ loggingOut = false }, vm::logout)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConfirmLogoutDialog(onCancel: () -> Unit, onLogout: (everywhere: Boolean) -> Unit) {
|
||||
Dialog(onDismissRequest = onCancel) {
|
||||
Surface(Modifier.padding(15.dp), shape = MaterialTheme.shapes.medium) {
|
||||
Column(Modifier.padding(8.dp).widthIn(min = 800.dp).width(IntrinsicSize.Max)) {
|
||||
Text("Confirm logout", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text("Are you sure you want to log out?")
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Row {
|
||||
Button(onCancel, Modifier.weight(0.33f)) {
|
||||
Text("Cancel")
|
||||
}
|
||||
Spacer(Modifier.width(5.dp))
|
||||
Button({ onLogout(true); onCancel() }, Modifier.weight(0.33f)) {
|
||||
Text("Log out everywhere")
|
||||
}
|
||||
Spacer(Modifier.width(5.dp))
|
||||
Button({ onLogout(false); onCancel() }, Modifier.weight(0.33f)) {
|
||||
Text("Log out")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun HomeView(onSelectProject: (Uuid) -> Unit) {
|
||||
val vm = viewModel { HomeVM() }
|
||||
|
||||
val auth = AuthProvider.get()
|
||||
val username by vm.username
|
||||
val isAdmin by vm.isAdmin
|
||||
val projectLimit by vm.projectLimit
|
||||
val ownProjects by vm.ownProjects
|
||||
val publicProjects by vm.publicProjects
|
||||
val session by auth.session
|
||||
|
||||
var ownFilter by remember { mutableStateOf("") }
|
||||
var publicFilter by remember { mutableStateOf("") }
|
||||
var publicUserFilterStr by remember { mutableStateOf("") }
|
||||
val publicUserFilter = remember { mutableStateSetOf<NamedID>() }
|
||||
val possibleUsers = remember { mutableStateListOf<NamedID>() }
|
||||
|
||||
var visibleOwnProjects by remember { mutableStateOf(ownProjects) }
|
||||
var visiblePublicProjects by remember { mutableStateOf(publicProjects) }
|
||||
|
||||
LaunchedEffect(ownFilter, ownProjects) {
|
||||
visibleOwnProjects =
|
||||
if(ownFilter == "") ownProjects
|
||||
else ownProjects.filter { ownFilter in it.name }
|
||||
}
|
||||
|
||||
LaunchedEffect(publicFilter, publicUserFilter.revision, publicProjects) {
|
||||
visiblePublicProjects = when {
|
||||
publicFilter == "" && publicUserFilter.isEmpty() -> publicProjects
|
||||
publicFilter == "" -> publicProjects.filter { it.owner in publicUserFilter }
|
||||
publicUserFilter.isEmpty() -> publicProjects.filter { publicFilter in it.name }
|
||||
else -> publicProjects.filter { publicFilter in it.name && it.owner in publicUserFilter }
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(publicProjects, publicUserFilterStr) {
|
||||
val allPublicUsers = publicProjects.map { it.owner }.toSet().sortedBy { it.name }
|
||||
possibleUsers.clear()
|
||||
if(publicUserFilterStr == "") {
|
||||
possibleUsers.addAll(allPublicUsers)
|
||||
}
|
||||
else {
|
||||
possibleUsers.addAll(allPublicUsers.filter { publicUserFilterStr in it.name })
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(session) { vm.refresh() }
|
||||
|
||||
var creatingProject by remember { mutableStateOf(false) }
|
||||
Column(Modifier.padding(all = 15.dp)) {
|
||||
Row(Modifier.height(IntrinsicSize.Min)) {
|
||||
Text(username?.let { "Welcome, $it" } ?: "Welcome", style = MaterialTheme.typography.headlineMedium)
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
|
||||
Row(Modifier.weight(0.6f)) {
|
||||
var leftHeaderHeight by remember { mutableStateOf(0.dp) }
|
||||
var rightFilterHeight by remember { mutableStateOf(0.dp) }
|
||||
val density = LocalDensity.current
|
||||
Surface(
|
||||
Modifier.padding(5.dp).weight(0.5f),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
tonalElevation = 5.dp
|
||||
) {
|
||||
Column(Modifier.padding(15.dp).fillMaxSize()) {
|
||||
Row(Modifier.onGloballyPositioned { leftHeaderHeight = with(density) { it.size.height.toDp() } }) {
|
||||
Row(Modifier.weight(1f)) {
|
||||
Text("Own projects: ", style = MaterialTheme.typography.headlineSmall)
|
||||
Text(
|
||||
"${ownProjects.size} / ${if (projectLimit == -1) "∞" else projectLimit}",
|
||||
Modifier.align(Alignment.Bottom)
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
{ creatingProject = true },
|
||||
enabled = projectLimit == -1 || ownProjects.size < projectLimit
|
||||
) {
|
||||
Icon(Lucide.Plus, "Create project")
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(5.dp))
|
||||
Row(Modifier.height(rightFilterHeight)) {
|
||||
OutlinedTextField(
|
||||
ownFilter,
|
||||
{ ownFilter = it },
|
||||
Modifier.fillMaxSize(),
|
||||
label = { Text("Filter by name...") })
|
||||
}
|
||||
Spacer(Modifier.height(5.dp))
|
||||
|
||||
if (ownProjects.isEmpty()) {
|
||||
Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) {
|
||||
Text("No projects yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic)
|
||||
}
|
||||
} else if(visibleOwnProjects.isEmpty()) {
|
||||
Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) {
|
||||
Text("No projects matching this filter.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic)
|
||||
}
|
||||
} else {
|
||||
LazyVerticalGrid(GridCells.Adaptive(minSize = 250.dp)) {
|
||||
items(visibleOwnProjects) {
|
||||
ProjectChip(it) { onSelectProject(it.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
Modifier.padding(5.dp).weight(0.5f),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
tonalElevation = 5.dp
|
||||
) {
|
||||
Column(Modifier.padding(15.dp).fillMaxSize()) {
|
||||
Row(Modifier.height(leftHeaderHeight), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Publicly accessible projects: ", style = MaterialTheme.typography.headlineSmall)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(5.dp))
|
||||
Row(Modifier.height(IntrinsicSize.Min).onGloballyPositioned { rightFilterHeight = with(density) { it.size.height.toDp() } }) {
|
||||
OutlinedTextField(publicFilter, { publicFilter = it },
|
||||
Modifier.weight(0.5f).fillMaxHeight(), label = { Text("Filter by name...") })
|
||||
Spacer(Modifier.width(5.dp))
|
||||
Box(Modifier.weight(0.5f)) {
|
||||
var isOpen by remember { mutableStateOf(false) }
|
||||
Surface(Modifier.fillMaxSize(), tonalElevation = 5.dp, shape = MaterialTheme.shapes.medium) {
|
||||
Row(Modifier.fillMaxSize().clickable { isOpen = true }.padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
publicUserFilter.toSet().forEach { user ->
|
||||
QuickUser(user) { publicUserFilter.remove(user) }
|
||||
}
|
||||
|
||||
if(publicUserFilter.isEmpty()) {
|
||||
Text("Filter by user...", fontStyle = FontStyle.Italic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DropdownMenu(isOpen, { isOpen = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { OutlinedTextField(publicUserFilterStr, { publicUserFilterStr = it }, Modifier.fillMaxWidth(), label = { Text("Filter by username...") }) },
|
||||
onClick = {}
|
||||
)
|
||||
|
||||
possibleUsers.forEach {
|
||||
DropdownMenuItem(
|
||||
text = { Text(it.name) },
|
||||
onClick = { publicUserFilter.toggle(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(5.dp))
|
||||
|
||||
if (publicProjects.isEmpty()) {
|
||||
Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) {
|
||||
Text("No projects yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic)
|
||||
}
|
||||
} else if (visiblePublicProjects.isEmpty()) {
|
||||
Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) {
|
||||
Text("No projects matching this filter.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic)
|
||||
}
|
||||
} else {
|
||||
LazyVerticalGrid(GridCells.Adaptive(minSize = 250.dp)) {
|
||||
items(visiblePublicProjects) {
|
||||
ProjectChip(it) { onSelectProject(it.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isAdmin) {
|
||||
val users by vm.users
|
||||
val invites by vm.invites
|
||||
var editing by remember { mutableStateOf<UserListResponse.UserData?>(null) }
|
||||
var deleting by remember { mutableStateOf<UserListResponse.UserData?>(null) }
|
||||
Row(Modifier.weight(0.4f).padding(15.dp)) {
|
||||
Surface(
|
||||
Modifier.padding(5.dp).weight(0.5f),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
tonalElevation = 5.dp
|
||||
) {
|
||||
Column(Modifier.padding(15.dp).fillMaxSize()) {
|
||||
Text("Users", style = MaterialTheme.typography.headlineSmall)
|
||||
|
||||
LazyColumn {
|
||||
items(users) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(it.name, Modifier.weight(0.25f))
|
||||
Text(if (it.isAdmin) "Admin" else "Regular user", Modifier.weight(0.25f))
|
||||
Text(
|
||||
"${it.usedProjects}/${if (it.projectLimit == -1) "∞" else it.projectLimit.toString()} projects",
|
||||
Modifier.weight(0.25f)
|
||||
)
|
||||
Row {
|
||||
IconButton({ editing = it }) {
|
||||
Icon(Lucide.Pencil, "Edit")
|
||||
}
|
||||
IconButton({ deleting = it }) {
|
||||
Icon(Lucide.Trash, "Delete")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
Modifier.padding(5.dp).weight(0.5f),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
tonalElevation = 5.dp
|
||||
) {
|
||||
Column(Modifier.padding(15.dp).fillMaxSize()) {
|
||||
val clip = LocalClipboard.current
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"Invite codes",
|
||||
Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
IconButton({ vm.mkInvite(clip, false) }) {
|
||||
Icon(Lucide.Plus, "Create invite")
|
||||
}
|
||||
IconButton({ vm.mkInvite(clip, true) }) {
|
||||
Icon(Lucide.ShieldPlus, "Create admin invite")
|
||||
}
|
||||
}
|
||||
|
||||
if (invites.isEmpty()) {
|
||||
Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) {
|
||||
Text(
|
||||
"No open invites.",
|
||||
Modifier.align(Alignment.Center),
|
||||
fontStyle = FontStyle.Italic
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn {
|
||||
items(invites) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(it.code.toString(), Modifier.weight(0.25f))
|
||||
if (it.expires.isPast()) {
|
||||
Text("Expired (${it.expires.fmt()})", Modifier.weight(0.25f))
|
||||
} else {
|
||||
Text("Valid (expires ${it.expires.fmt()})", Modifier.weight(0.25f))
|
||||
}
|
||||
Text(if (it.asAdmin) "Admin invite" else "", Modifier.weight(0.25f))
|
||||
|
||||
IconButton({ vm.deleteInvite(it.code) }) {
|
||||
Icon(Lucide.Trash, "Delete")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
editing?.let {
|
||||
EditUserDialog(it, { editing = null }) { isAdmin, projectLimit ->
|
||||
vm.updateUser(it.id, isAdmin, projectLimit)
|
||||
}
|
||||
}
|
||||
|
||||
deleting?.let {
|
||||
ConfirmDeleteUserDialog(it, { deleting = null }) {
|
||||
vm.deleteUser(it.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (creatingProject) CreateProjectDialog({ creatingProject = false }, vm::mkProject)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun QuickUser(user: NamedID, onClickCross: () -> Unit) {
|
||||
Surface(tonalElevation = 10.dp, color = MaterialTheme.colorScheme.surfaceVariant, shadowElevation = 1.dp, shape = MaterialTheme.shapes.medium) {
|
||||
Row(Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(user.name)
|
||||
Spacer(Modifier.width(5.dp))
|
||||
IconButton(onClickCross) {
|
||||
Icon(Lucide.X, "Remove")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EditUserDialog(user: UserListResponse.UserData, onCancel: () -> Unit, onUpdate: (isAdmin: Boolean?, projectLimit: Int?) -> Unit) {
|
||||
Dialog(onDismissRequest = onCancel) {
|
||||
var isAdmin by remember { mutableStateOf(user.isAdmin) }
|
||||
var projectLimit by remember { mutableStateOf(user.projectLimit) }
|
||||
Surface(Modifier.padding(15.dp), shape = MaterialTheme.shapes.medium) {
|
||||
Column(Modifier.padding(8.dp).width(IntrinsicSize.Min)) {
|
||||
Text("Editing user ${user.name}", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(isAdmin, { isAdmin = it })
|
||||
Text(if(isAdmin) "Admin" else "Regular user")
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
OutlinedTextField(if(projectLimit == -1) "∞" else projectLimit.toString(),
|
||||
{ projectLimit = it.toIntOrNull() ?: 0 }, Modifier.fillMaxWidth(),
|
||||
label = { Text("Project limit") }, enabled = projectLimit != -1)
|
||||
Row(Modifier.align(Alignment.End), verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(projectLimit == -1, { projectLimit = if(projectLimit == -1) 1 else -1 })
|
||||
Text("No project limit")
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
|
||||
CancelConfirmXRow(onCancel, {
|
||||
onUpdate(isAdmin nonEq user.isAdmin, projectLimit nonEq user.projectLimit)
|
||||
}, confirmText = "Save")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConfirmDeleteUserDialog(user: UserListResponse.UserData, onCancel: () -> Unit, onDelete: () -> Unit) {
|
||||
Dialog(onDismissRequest = onCancel) {
|
||||
Surface(Modifier.padding(15.dp), shape = MaterialTheme.shapes.medium) {
|
||||
Column(Modifier.padding(8.dp).widthIn(min = 250.dp).width(IntrinsicSize.Min)) {
|
||||
Text("Confirm deletion", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text("Are you sure you want to delete ${user.name}?")
|
||||
Spacer(Modifier.height(10.dp))
|
||||
CancelConfirmXRow(onCancel, onDelete, confirmText = "Delete")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateProjectDialog(onCancel: () -> Unit, onCreate: (name: String, isPublic: Boolean) -> Unit) {
|
||||
Dialog(onDismissRequest = onCancel) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
var isPublic by remember { mutableStateOf(false) }
|
||||
|
||||
Surface(Modifier.padding(15.dp), shape = MaterialTheme.shapes.medium) {
|
||||
Column(Modifier.padding(8.dp).width(IntrinsicSize.Min)) {
|
||||
Text("Create project", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium)
|
||||
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth(), label = { Text("Name") })
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Row(Modifier.align(Alignment.End), verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(isPublic, { isPublic = it })
|
||||
Text("Public project")
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
|
||||
CancelConfirmXRow(onCancel, {
|
||||
onCreate(name.trim(), isPublic)
|
||||
}, confirmText = "Create", canConfirm = name.trim().isNotBlank())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProjectChip(summary: HomeResponse.ProjectSummary, onOpen: () -> Unit) {
|
||||
Surface(Modifier.padding(10.dp), tonalElevation = 10.dp, shape = MaterialTheme.shapes.medium) {
|
||||
Column(Modifier.clickable(onClick = onOpen).padding(15.dp)) {
|
||||
Text(summary.name, style = MaterialTheme.typography.headlineSmall)
|
||||
Spacer(Modifier.height(5.dp))
|
||||
Column(Modifier.padding(start = 15.dp)) {
|
||||
Text("${if(summary.isPublic) "Public" else "Private"} project")
|
||||
Text("By user ${summary.owner.name}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.jaytux.phoebench.clients.ui
|
||||
|
||||
import androidx.compose.foundation.IndicationNodeFactory
|
||||
import androidx.compose.foundation.interaction.InteractionSource
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
|
||||
import androidx.compose.ui.node.DelegatableNode
|
||||
import androidx.compose.ui.node.DrawModifierNode
|
||||
|
||||
class NoFeedbackIndication : IndicationNodeFactory {
|
||||
override fun create(interactionSource: InteractionSource): DelegatableNode = NoFeedbackNode(interactionSource)
|
||||
override fun hashCode(): Int = -1
|
||||
override fun equals(other: Any?): Boolean = other is NoFeedbackIndication
|
||||
|
||||
class NoFeedbackNode(private val interactionSource: InteractionSource) : Modifier.Node(), DrawModifierNode {
|
||||
override fun ContentDrawScope.draw() = drawContent()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
package com.jaytux.phoebench.clients.ui
|
||||
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.composables.icons.lucide.*
|
||||
import com.jaytux.phoebench.clients.darken
|
||||
import com.jaytux.phoebench.clients.data.ProjectVM
|
||||
import com.jaytux.phoebench.clients.data.mutableStateSetFrom
|
||||
import com.jaytux.phoebench.clients.dualLerp
|
||||
import com.jaytux.phoebench.clients.fmt
|
||||
import com.jaytux.phoebench.clients.fmtRange
|
||||
import com.jaytux.phoebench.clients.inRange
|
||||
import com.jaytux.phoebench.clients.nonEq
|
||||
import com.jaytux.phoebench.clients.randomColor
|
||||
import com.jaytux.phoebench.common.Either
|
||||
import com.jaytux.phoebench.common.TimeUnit
|
||||
import com.jaytux.phoebench.common.asError
|
||||
import com.jaytux.phoebench.common.asValue
|
||||
import com.jaytux.phoebench.common.error
|
||||
import com.jaytux.phoebench.common.isValue
|
||||
import com.jaytux.phoebench.common.value
|
||||
import com.kborowy.colorpicker.KolorPicker
|
||||
import io.github.koalaplot.core.line.LinePlot
|
||||
import io.github.koalaplot.core.style.KoalaPlotTheme
|
||||
import io.github.koalaplot.core.style.LineStyle
|
||||
import io.github.koalaplot.core.xygraph.AxisContent
|
||||
import io.github.koalaplot.core.xygraph.DefaultPoint
|
||||
import io.github.koalaplot.core.xygraph.XYGraph
|
||||
import io.github.koalaplot.core.xygraph.autoScaleRange
|
||||
import io.github.koalaplot.core.xygraph.autoScaleXRange
|
||||
import io.github.koalaplot.core.xygraph.autoScaleYRange
|
||||
import io.github.koalaplot.core.xygraph.rememberAxisStyle
|
||||
import io.github.koalaplot.core.xygraph.rememberFloatLinearAxisModel
|
||||
import io.github.koalaplot.core.xygraph.rememberGridStyle
|
||||
import kotlin.time.Instant
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
@Composable
|
||||
fun ProjectView(id: Uuid, forceBack: () -> Unit) {
|
||||
val vm = viewModel(key = id.toString()) { ProjectVM(id, _forceBack = forceBack) }
|
||||
|
||||
val name by vm.name
|
||||
val owner by vm.owner
|
||||
val public by vm.public
|
||||
val editable by vm.editable
|
||||
val labels by vm.labels
|
||||
|
||||
var editing by remember { mutableStateOf(false) }
|
||||
var deleting by remember { mutableStateOf(false) }
|
||||
var addOpen by remember { mutableStateOf(false) }
|
||||
var addingLabel by remember { mutableStateOf(false) }
|
||||
var addingData by remember { mutableStateOf(false) }
|
||||
|
||||
Column(Modifier.padding(all = 15.dp)) {
|
||||
Row(Modifier.height(IntrinsicSize.Min), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Project ${name ?: "Unnamed Project"}", style = MaterialTheme.typography.headlineMedium)
|
||||
if(editable) {
|
||||
Spacer(Modifier.width(25.dp))
|
||||
IconButton({ editing = true }) {
|
||||
Icon(Lucide.Pencil, "Edit project details")
|
||||
}
|
||||
IconButton({ deleting = true }) {
|
||||
Icon(Lucide.Trash, "Delete project")
|
||||
}
|
||||
}
|
||||
}
|
||||
owner?.let { Text("${if(public) "Public" else "Private"} project by user $it") }
|
||||
Spacer(Modifier.height(15.dp))
|
||||
Surface(Modifier.fillMaxSize().padding(8.dp), tonalElevation = 10.dp, shape = MaterialTheme.shapes.medium) {
|
||||
Box(Modifier.fillMaxSize().padding(15.dp)) {
|
||||
ProjectPlotArea(vm)
|
||||
|
||||
if(addOpen) {
|
||||
CompositionLocalProvider(LocalIndication provides NoFeedbackIndication()) {
|
||||
Box(Modifier.clickable { addOpen = false }.fillMaxSize())
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.align(Alignment.BottomEnd).padding(25.dp), horizontalAlignment = Alignment.End) {
|
||||
if(addOpen) {
|
||||
var lblWidth by mutableStateOf(1000.dp)
|
||||
var dataWidth by mutableStateOf(1000.dp)
|
||||
val density = LocalDensity.current
|
||||
|
||||
Column(Modifier.width(maxOf(lblWidth, dataWidth)), horizontalAlignment = Alignment.End) {
|
||||
Surface(Modifier.onGloballyPositioned {
|
||||
lblWidth = with(density) { it.size.width.toDp() }
|
||||
}, shape = MaterialTheme.shapes.small, shadowElevation = 10.dp) {
|
||||
Row(Modifier.clickable { addingLabel = true }.padding(15.dp)) {
|
||||
Icon(Lucide.CaseSensitive, "Add label")
|
||||
Spacer(Modifier.width(5.dp))
|
||||
Text("Label")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(10.dp))
|
||||
|
||||
Surface(Modifier.onGloballyPositioned {
|
||||
dataWidth = with(density) { it.size.width.toDp() }
|
||||
}, shape = MaterialTheme.shapes.small, shadowElevation = 10.dp) {
|
||||
Row(Modifier.clickable { addingData = true }.padding(15.dp)) {
|
||||
Icon(Lucide.ChartLine, "Add data")
|
||||
Spacer(Modifier.width(5.dp))
|
||||
Text("Data")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(15.dp))
|
||||
}
|
||||
}
|
||||
|
||||
IconButton({ addOpen = !addOpen }, Modifier.scale(1.5f), shape = MaterialTheme.shapes.medium) {
|
||||
Icon(if(addOpen) Lucide.X else Lucide.Plus, if(addOpen) "Add data" else "Close menu")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(editing) ProjectEditDialog(name ?: "Unnamed Project", public, { editing = false }) { name, public ->
|
||||
vm.update(name, public)
|
||||
}
|
||||
|
||||
if(deleting) ConfirmDeleteProjectDialog(name ?: "Unnamed Project", { deleting = false }) {
|
||||
vm.delete()
|
||||
}
|
||||
|
||||
if(addingLabel) AddLabelDialog({ addingLabel = false; addOpen = false }) { name, color ->
|
||||
vm.mkLabel(name, color)
|
||||
addOpen = false
|
||||
}
|
||||
|
||||
if(addingData) AddEntryDialog(labels,
|
||||
onCancel = { addingData = false; addOpen = false },
|
||||
onAddLbl = { name, lbl -> vm.mkLabel(name, lbl) }
|
||||
) { label, warmups, measurements, unit ->
|
||||
vm.mkEntry(label, warmups, measurements, unit)
|
||||
addOpen = false
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProjectEditDialog(name: String, public: Boolean, onCancel: () -> Unit, onSave: (name: String?, public: Boolean?) -> Unit) {
|
||||
Dialog(onDismissRequest = onCancel) {
|
||||
var updName by remember { mutableStateOf(name) }
|
||||
var isPublic by remember { mutableStateOf(public) }
|
||||
|
||||
Surface(Modifier.padding(15.dp), shape = MaterialTheme.shapes.medium) {
|
||||
Column(Modifier.padding(8.dp).width(IntrinsicSize.Min)) {
|
||||
Text("Create project", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium)
|
||||
OutlinedTextField(updName, { updName = it }, Modifier.fillMaxWidth(), label = { Text("Name") })
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Row(Modifier.align(Alignment.End), verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(isPublic, { isPublic = it })
|
||||
Text("Public project")
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
|
||||
CancelConfirmXRow(onCancel, {
|
||||
onSave(updName.trim() nonEq name, isPublic nonEq public)
|
||||
}, confirmText = "Save", canConfirm = updName.trim().isNotBlank())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConfirmDeleteProjectDialog(name: String, onCancel: () -> Unit, onDelete: () -> Unit) {
|
||||
Dialog(onDismissRequest = onCancel) {
|
||||
Surface(Modifier.padding(15.dp), shape = MaterialTheme.shapes.medium) {
|
||||
Column(Modifier.padding(8.dp).widthIn(min = 250.dp).width(IntrinsicSize.Min)) {
|
||||
Text("Confirm deletion", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text("Are you sure you want to delete ${name}?")
|
||||
Spacer(Modifier.height(10.dp))
|
||||
CancelConfirmXRow(onCancel, onDelete, confirmText = "Delete")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddLabelDialog(onCancel: () -> Unit, onAdd: (name: String, color: Color) -> Unit) {
|
||||
Dialog(onDismissRequest = onCancel) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
var color by remember { mutableStateOf(randomColor()) }
|
||||
|
||||
Surface(Modifier.padding(15.dp).widthIn(400.dp), shape = MaterialTheme.shapes.medium) {
|
||||
Column(Modifier.padding(8.dp).width(IntrinsicSize.Min)) {
|
||||
Text("Create label", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium)
|
||||
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth(), label = { Text("Name") })
|
||||
Spacer(Modifier.height(10.dp))
|
||||
KolorPicker(color, { color = it }, alphaTrackVisible = false, modifier = Modifier.aspectRatio(1f))
|
||||
Spacer(Modifier.height(10.dp))
|
||||
|
||||
CancelConfirmXRow(onCancel, {
|
||||
onAdd(name.trim(), color)
|
||||
}, confirmText = "Create", canConfirm = name.trim().isNotBlank())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun QuickLabel(lbl: ProjectVM.Label) {
|
||||
Row(Modifier.height(IntrinsicSize.Min).padding(vertical = 3.dp), verticalAlignment = Alignment.Bottom) {
|
||||
Box(Modifier.fillMaxHeight().aspectRatio(1f).background(lbl.uiColor)) {}
|
||||
Spacer(Modifier.width(15.dp))
|
||||
Text(lbl.name)
|
||||
Text(" ${lbl.colorStr}", style = MaterialTheme.typography.bodySmall, color = LocalContentColor.current.copy(alpha = 0.75f))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddEntryDialog(
|
||||
labels: Map<Uuid, ProjectVM.Label>,
|
||||
onCancel: () -> Unit, onAddLbl: (name: String, color: Color) -> Unit,
|
||||
onAdd: (label: ProjectVM.Label, warmups: List<Float>, measurements: List<Float>, unit: TimeUnit) -> Unit
|
||||
) {
|
||||
Dialog(onDismissRequest = onCancel) {
|
||||
var label by remember { mutableStateOf<ProjectVM.Label?>(null) }
|
||||
var warmups by remember { mutableStateOf("") }
|
||||
var measurements by remember { mutableStateOf("") }
|
||||
var unit by remember { mutableStateOf(TimeUnit.SECONDS) }
|
||||
|
||||
var addingLabel by remember { mutableStateOf(false) }
|
||||
var warmupParsed by remember { mutableStateOf<Either<String, List<Float>>>(listOf<Float>().value()) }
|
||||
var measureParsed by remember { mutableStateOf<Either<String, List<Float>>>(listOf<Float>().value()) }
|
||||
|
||||
fun parse(text: String): Either<String, List<Float>> {
|
||||
val parts = text.split(',')
|
||||
val parsed = ArrayList<Float>(parts.size)
|
||||
val invalids = mutableListOf<String>()
|
||||
parts.forEach {
|
||||
val trim = it.trim()
|
||||
if(trim.isEmpty()) return@forEach
|
||||
trim.toFloatOrNull()?.let { f -> parsed += f } ?: run { invalids += trim }
|
||||
}
|
||||
return if(invalids.isEmpty()) parsed.value()
|
||||
else "Invalid elements: ${invalids.joinToString(", ") { "'$it'" }}".error()
|
||||
}
|
||||
|
||||
LaunchedEffect(warmups) { warmupParsed = parse(warmups) }
|
||||
|
||||
LaunchedEffect(measurements) { measureParsed = parse(measurements) }
|
||||
|
||||
Surface(Modifier.padding(15.dp).widthIn(400.dp), shape = MaterialTheme.shapes.medium) {
|
||||
Column(Modifier.padding(8.dp).width(IntrinsicSize.Min)) {
|
||||
Text("Create data entry", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium)
|
||||
Spacer(Modifier.height(5.dp))
|
||||
var dropDownOpen by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
Surface(
|
||||
Modifier.fillMaxWidth().padding(8.dp),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
tonalElevation = 2.dp
|
||||
) {
|
||||
Box(Modifier.fillMaxWidth().clickable { dropDownOpen = true }.padding(8.dp)) {
|
||||
label?.let { QuickLabel(it) } ?: Text("Select label...", fontStyle = FontStyle.Italic)
|
||||
}
|
||||
}
|
||||
DropdownMenu(dropDownOpen, { dropDownOpen = false }) {
|
||||
labels.values.forEach { lbl ->
|
||||
DropdownMenuItem({ QuickLabel(lbl) }, { label = lbl; dropDownOpen = false })
|
||||
}
|
||||
HorizontalDivider(Modifier.height(1.dp))
|
||||
|
||||
DropdownMenuItem({
|
||||
Text(
|
||||
"Create new label...",
|
||||
color = LocalContentColor.current.copy(alpha = 0.75f),
|
||||
fontStyle = FontStyle.Italic
|
||||
)
|
||||
}, { addingLabel = true })
|
||||
}
|
||||
}
|
||||
|
||||
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
|
||||
TimeUnit.entries.forEachIndexed { idx, it ->
|
||||
SegmentedButton(unit == it, { unit = it }, shape = SegmentedButtonDefaults.itemShape(idx, TimeUnit.entries.size)) {
|
||||
Text(it.disp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(7.dp))
|
||||
Text("Data (separate data points by commas)")
|
||||
Spacer(Modifier.height(5.dp))
|
||||
OutlinedTextField(warmups, { warmups = it }, Modifier.fillMaxWidth(),
|
||||
singleLine = false, minLines = 10, label = { Text("Warmup data") })
|
||||
warmupParsed.asError()?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error, fontStyle = FontStyle.Italic)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(5.dp))
|
||||
OutlinedTextField(measurements, { measurements = it }, Modifier.fillMaxWidth(),
|
||||
singleLine = false, minLines = 10, label = { Text("(Steady-state) Measurements") })
|
||||
measureParsed.asError()?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error, fontStyle = FontStyle.Italic)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(5.dp))
|
||||
CancelConfirmXRow(onCancel, {
|
||||
onAdd(label!!, warmupParsed.asValue()!!, measureParsed.asValue()!!, unit)
|
||||
}, confirmText = "Create", canConfirm = label != null && warmupParsed.isValue() && measureParsed.isValue())
|
||||
}
|
||||
}
|
||||
|
||||
if(addingLabel) AddLabelDialog({ addingLabel = false }, onAddLbl)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProjectPlotArea(vm: ProjectVM){
|
||||
val entries by vm.entries
|
||||
val labels by vm.labels
|
||||
val editable by vm.editable
|
||||
|
||||
var displayWarmups by remember { mutableStateOf(false) }
|
||||
var currentUnit by remember { mutableStateOf(TimeUnit.SECONDS) }
|
||||
var renderableEntries by remember { mutableStateOf(listOf<Pair<List<DefaultPoint<Float, Float>>, Color>>()) }
|
||||
var xMax by remember { mutableStateOf(1f) }
|
||||
var yMin by remember { mutableStateOf(0f) }
|
||||
var yMax by remember { mutableStateOf(1f) }
|
||||
|
||||
var timeMin by remember { mutableStateOf(Instant.DISTANT_PAST) }
|
||||
var timeMax by remember { mutableStateOf(Instant.DISTANT_FUTURE) }
|
||||
var timeFilter by remember { mutableStateOf(0f..1f) }
|
||||
var timeFilterString by remember { mutableStateOf("") }
|
||||
|
||||
val labelFilter = remember(labels) { mutableStateSetFrom(labels.values) }
|
||||
val labelFilterKey by labelFilter.revision
|
||||
|
||||
LaunchedEffect(entries, displayWarmups, currentUnit, timeFilter, timeMin, timeMax, labelFilterKey) {
|
||||
var maxX = 0f
|
||||
var minY = 0f
|
||||
var maxY = 0f
|
||||
val timeRange = dualLerp(timeMin, timeMax, timeFilter.start, timeFilter.endInclusive)
|
||||
renderableEntries = entries.mapNotNull { entry ->
|
||||
val use = if(displayWarmups) entry.warmups else entry.measurements
|
||||
maxX = maxOf(maxX, use.size.toFloat())
|
||||
|
||||
if(entry.label !in labelFilter) return@mapNotNull null
|
||||
if(!(entry.timeStamp inRange timeRange)) return@mapNotNull null
|
||||
|
||||
use.mapIndexed { idx, it ->
|
||||
val converted = entry.nativeUnit.convertTo(currentUnit, it)
|
||||
minY = minOf(minY, converted)
|
||||
maxY = maxOf(maxY, converted)
|
||||
|
||||
DefaultPoint(idx.toFloat(), converted)
|
||||
} to entry.label.uiColor
|
||||
}
|
||||
xMax = maxX
|
||||
yMin = minY
|
||||
yMax = maxY
|
||||
}
|
||||
|
||||
LaunchedEffect(entries) {
|
||||
timeMin = entries.minOfOrNull { it.timeStamp } ?: Instant.DISTANT_PAST
|
||||
timeMax = entries.maxOfOrNull { it.timeStamp } ?: Instant.DISTANT_FUTURE
|
||||
}
|
||||
|
||||
LaunchedEffect(timeMin, timeMax, timeFilter) {
|
||||
timeFilterString = dualLerp(timeMin, timeMax, timeFilter.start, timeFilter.endInclusive).fmtRange()
|
||||
}
|
||||
|
||||
Row(Modifier.fillMaxSize().padding(20.dp)) {
|
||||
Box(Modifier.weight(0.66f).fillMaxHeight()) {
|
||||
if(entries.isEmpty()) {
|
||||
Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) {
|
||||
Text("No data yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic)
|
||||
}
|
||||
}
|
||||
else {
|
||||
Column {
|
||||
Text("Measurements", style = MaterialTheme.typography.headlineSmall)
|
||||
Row {
|
||||
Row(Modifier.weight(0.66f), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("View as: ", fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.width(5.dp))
|
||||
SingleChoiceSegmentedButtonRow(Modifier.weight(1f)) {
|
||||
TimeUnit.entries.forEachIndexed { idx, it ->
|
||||
SegmentedButton(
|
||||
currentUnit == it,
|
||||
{ currentUnit = it },
|
||||
shape = SegmentedButtonDefaults.itemShape(idx, TimeUnit.entries.size)
|
||||
) {
|
||||
Text(it.disp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(50.dp))
|
||||
|
||||
Row(Modifier.weight(0.33f), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Display: ", fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.width(5.dp))
|
||||
SingleChoiceSegmentedButtonRow(Modifier.weight(1f)) {
|
||||
SegmentedButton(!displayWarmups, { displayWarmups = false },
|
||||
shape = SegmentedButtonDefaults.itemShape(0, 2)
|
||||
) {
|
||||
Text("(Steady-state) measurements")
|
||||
}
|
||||
|
||||
SegmentedButton(displayWarmups, { displayWarmups = true },
|
||||
shape = SegmentedButtonDefaults.itemShape(1, 2)
|
||||
) {
|
||||
Text("Warmup measurements")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(5.dp))
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Filter by timestamp: ", fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.width(5.dp))
|
||||
RangeSlider(timeFilter, { timeFilter = it }, Modifier.fillMaxWidth(), valueRange = 0f..1f)
|
||||
}
|
||||
Text(timeFilterString, Modifier.align(Alignment.End))
|
||||
}
|
||||
Spacer(Modifier.height(15.dp))
|
||||
|
||||
val style = rememberAxisStyle()
|
||||
val lineColor = SolidColor(Color.LightGray.copy(alpha = 0.25f))
|
||||
val gridStyle = rememberGridStyle(
|
||||
horizontalMajorStyle = KoalaPlotTheme.axis.majorGridlineStyle?.copy(brush = lineColor),
|
||||
horizontalMinorStyle = KoalaPlotTheme.axis.minorGridlineStyle?.copy(brush = lineColor),
|
||||
verticalMajorStyle = KoalaPlotTheme.axis.majorGridlineStyle?.copy(brush = lineColor),
|
||||
verticalMinorStyle = KoalaPlotTheme.axis.minorGridlineStyle?.copy(brush = lineColor)
|
||||
)
|
||||
XYGraph(
|
||||
rememberFloatLinearAxisModel(listOf(0f, xMax).autoScaleRange()),
|
||||
rememberFloatLinearAxisModel(listOf(yMin, yMax).autoScaleRange()),
|
||||
xAxisContent = AxisContent(
|
||||
labels = { AxisLabel(it.fmt()) },
|
||||
title = {},
|
||||
style = style
|
||||
),
|
||||
yAxisContent = AxisContent(
|
||||
labels = { AxisLabel(it.fmt()) },
|
||||
title = {},
|
||||
style = style
|
||||
),
|
||||
modifier = Modifier.weight(1f),
|
||||
gridStyle = gridStyle
|
||||
) {
|
||||
renderableEntries.forEach { (data, color) ->
|
||||
LinePlot(data, lineStyle = LineStyle(SolidColor(color), strokeWidth = 3.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(20.dp))
|
||||
|
||||
Box(Modifier.weight(0.33f).fillMaxHeight()) {
|
||||
Column {
|
||||
Text("Labels", style = MaterialTheme.typography.headlineSmall)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
if(labels.isEmpty()) {
|
||||
Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) {
|
||||
Text("No labels yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic)
|
||||
}
|
||||
}
|
||||
else {
|
||||
LazyColumn(Modifier.padding(start = 5.dp)) {
|
||||
items(labels.toList()) { (_, lbl) ->
|
||||
Box(Modifier.fillMaxWidth().clickable { labelFilter.toggle(lbl) }) {
|
||||
Box {
|
||||
QuickLabel(lbl)
|
||||
|
||||
if(lbl !in labelFilter) {
|
||||
Box(Modifier.matchParentSize()) {
|
||||
HorizontalDivider(
|
||||
Modifier.fillMaxWidth().align(Alignment.Center),
|
||||
thickness = 3.dp,
|
||||
color = LocalContentColor.current
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AxisLabel(
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = modifier,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.jaytux.phoebench.clients.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.jaytux.phoebench.clients.nonEq
|
||||
|
||||
@Composable
|
||||
fun CancelConfirmRow(
|
||||
onCancel: () -> Unit, onConfirm: () -> Unit, confirmText: String = "Confirm", cancelText: String = "Cancel",
|
||||
modifier: Modifier = Modifier, canConfirm: Boolean = true
|
||||
) {
|
||||
Row(modifier) {
|
||||
Button(onCancel, Modifier.weight(0.5f)) {
|
||||
Text(cancelText)
|
||||
}
|
||||
Spacer(Modifier.width(5.dp))
|
||||
Button(onConfirm, Modifier.weight(0.5f), enabled = canConfirm) {
|
||||
Text(confirmText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CancelConfirmXRow(
|
||||
onCancel: () -> Unit, onConfirm: () -> Unit, confirmText: String = "Confirm", cancelText: String = "Cancel",
|
||||
modifier: Modifier = Modifier, canConfirm: Boolean = true
|
||||
) = CancelConfirmRow(onCancel, { onConfirm(); onCancel() }, confirmText, cancelText, modifier, canConfirm)
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.jaytux.phoebench.clients
|
||||
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
|
||||
fun main() = application {
|
||||
Window(onCloseRequest = ::exitApplication, title = "PhoeBench") {
|
||||
App()
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package com.jaytux.phoebench.clients
|
||||
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import com.github.javakeyring.Keyring
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.engine.okhttp.*
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.serializer
|
||||
import java.awt.datatransfer.StringSelection
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
object JVMStore : IStore {
|
||||
private val json = Json
|
||||
const val SERVICE = "com.jaytux.phoebench"
|
||||
|
||||
private class StoredProperty<T>(
|
||||
val key: String,
|
||||
val toString: (T) -> String, val fromString: (String) -> T
|
||||
) : IStore.IStoredProperty<T> {
|
||||
constructor(key: String, serializer: KSerializer<T>) : this(key,
|
||||
{ json.encodeToString(serializer, it) },
|
||||
{ json.decodeFromString(serializer, it) }
|
||||
)
|
||||
|
||||
override fun load(): T? = runCatching {
|
||||
val keyring = Keyring.create()
|
||||
keyring.getPassword(SERVICE, key)
|
||||
}.getOrNull()?.let { fromString(it) }
|
||||
|
||||
override fun save(value: T) = runCatching {
|
||||
val keyring = Keyring.create()
|
||||
keyring.setPassword(SERVICE, key, toString(value))
|
||||
}.onFailure { println("Failed to write to OS keyring: ${it.message}") }.ignore()
|
||||
|
||||
override fun erase() = runCatching {
|
||||
val keyring = Keyring.create()
|
||||
keyring.deletePassword(SERVICE, key)
|
||||
}.ignore()
|
||||
}
|
||||
|
||||
override fun refreshToken(): IStore.IStoredProperty<Uuid> =
|
||||
StoredProperty("refresh_token", serializer<Uuid>())
|
||||
|
||||
override fun server(): IStore.IStoredProperty<String> =
|
||||
StoredProperty("server_url", {it}, {it})
|
||||
}
|
||||
|
||||
actual fun persistentStore(): IStore = JVMStore
|
||||
|
||||
actual fun platformClient(builder: HttpClientConfig<*>.() -> Unit): HttpClient = HttpClient(OkHttp) {
|
||||
builder()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
actual suspend fun String.toClipEntry(): ClipEntry =
|
||||
ClipEntry(StringSelection(this))
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.jaytux.phoebench.clients
|
||||
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.window.ComposeViewport
|
||||
import kotlinx.browser.document
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
fun main() {
|
||||
ComposeViewport(document.body!!) {
|
||||
App()
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.jaytux.phoebench.clients
|
||||
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.engine.js.*
|
||||
import kotlinx.browser.window
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.serializer
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
object WasmJsStore : IStore {
|
||||
private val json = Json
|
||||
private class StoredProperty<T>(
|
||||
val key: String,
|
||||
val toString: (T) -> String, val fromString: (String) -> T
|
||||
) : IStore.IStoredProperty<T> {
|
||||
constructor(key: String, serializer: KSerializer<T>) : this(key,
|
||||
{ json.encodeToString(serializer, it) },
|
||||
{ json.decodeFromString(serializer, it) }
|
||||
)
|
||||
override fun load(): T? = runCatching {
|
||||
window.localStorage.getItem(key)
|
||||
}.getOrNull()?.let { fromString(it) }
|
||||
override fun save(value: T) = runCatching {
|
||||
window.localStorage.setItem(key, toString(value))
|
||||
}.ignore()
|
||||
|
||||
override fun erase() = runCatching {
|
||||
window.localStorage.removeItem(key)
|
||||
}.ignore()
|
||||
|
||||
}
|
||||
|
||||
override fun refreshToken(): IStore.IStoredProperty<Uuid> =
|
||||
StoredProperty("refresh_token", serializer<Uuid>())
|
||||
|
||||
override fun server(): IStore.IStoredProperty<String> =
|
||||
StoredProperty("server_url", {it}, {it})
|
||||
}
|
||||
|
||||
actual fun persistentStore(): IStore = WasmJsStore
|
||||
|
||||
actual fun platformClient(builder: HttpClientConfig<*>.() -> Unit): HttpClient = HttpClient(Js) {
|
||||
builder()
|
||||
}
|
||||
|
||||
actual suspend fun String.toClipEntry(): ClipEntry =
|
||||
ClipEntry.withPlainText(this)
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PhoeBench</title>
|
||||
<link type="text/css" rel="stylesheet" href="styles.css">
|
||||
<script type="application/javascript" src="composeApp.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,7 @@
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
Reference in New Issue
Block a user