Initial version (Server, Compose clients work)

This commit is contained in:
2026-08-04 12:12:25 +02:00
commit 2a1487037b
59 changed files with 4701 additions and 0 deletions
+102
View File
@@ -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,99 @@
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 {
_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,112 @@
package com.jaytux.phoebench.clients
import com.jaytux.phoebench.common.ApiRoute
import com.jaytux.phoebench.common.Either
import com.jaytux.phoebench.common.EmptyRequest
import com.jaytux.phoebench.common.ErrorResponse
import com.jaytux.phoebench.common.IClient
import com.jaytux.phoebench.common.LogoutRequest
import com.jaytux.phoebench.common.RefreshRequest
import com.jaytux.phoebench.common.Routes
import com.jaytux.phoebench.common.error
import com.jaytux.phoebench.common.foldSuspend
import io.ktor.client.HttpClient
import io.ktor.client.plugins.api.createClientPlugin
import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.authProvider
import io.ktor.client.plugins.auth.providers.BearerAuthProvider
import io.ktor.client.plugins.auth.providers.bearer
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import io.ktor.utils.io.CancellationException
import kotlinx.atomicfu.locks.ReentrantLock
import kotlinx.atomicfu.locks.withLock
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.datetime.TimeZone
import kotlin.uuid.Uuid
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 {
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
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,161 @@
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.")
}
}
_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)
}
}
@@ -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)
}
}
@@ -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 = 600.dp).width(IntrinsicSize.Min)) {
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}")
}
}
}
}
@@ -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()
}
}
@@ -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()
}
}
@@ -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;
}