Reworked and improved in fact almost all

This commit is contained in:
Thraax Session 2023-11-08 22:21:05 +01:00
parent fa96c879ae
commit 9d905564c2
30 changed files with 1127 additions and 455 deletions

View File

@ -27,20 +27,24 @@ repositories {
dependencies {
implementation(project(":common"))
implementation("androidx.activity:activity-compose:1.7.2")
//implementation("androidx.activity:activity-compose:1.8.0") // for SDK Version 34
}
android {
compileSdk = 33
defaultConfig {
applicationId = "technology.iatlas.spaceup.android"
minSdk = 24
targetSdk = 33
minSdk = 28
targetSdk = 33
versionCode = 1
versionName = "1.0-SNAPSHOT"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildTypes {
getByName("release") {

View File

@ -1,11 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:icon="@mipmap/launcher_icon"
android:label="SpaceUp-NextUI"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.AppCompat.Light.NoActionBar">
android:theme="@style/Theme.AppCompat.DayNight">
<activity android:name=".MainActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
@ -20,6 +21,5 @@
<data android:scheme="https"/>
</intent>
</queries>
<uses-permission android:name="android.permission.INTERNET"/>
<!--uses-permission android:name="android.permission.USE_BIOMETRIC" /-->
</manifest>

View File

@ -1,19 +1,26 @@
package technology.iatlas.spaceup.android
import android.os.Bundle
import androidx.compose.material3.Surface
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.core.view.WindowCompat
import moe.tlaster.precompose.lifecycle.PreComposeActivity
import moe.tlaster.precompose.lifecycle.setContent
import moe.tlaster.precompose.PreComposeApp
import technology.iatlas.spaceup.common.App
class MainActivity : PreComposeActivity() {
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// add FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS flag to the window
window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
// Turn off the decor fitting system windows
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
App()
WindowCompat.setDecorFitsSystemWindows(window, false);
setContent{
PreComposeApp {
App()
}
}
}
}

View File

@ -2,7 +2,7 @@ plugins {
kotlin("multiplatform")
id("org.jetbrains.compose")
id("com.android.library")
kotlin("plugin.serialization") version "1.9.10"
kotlin("plugin.serialization") version "1.9.20"
}
group = "technology.iatlas.spaceup"
@ -22,12 +22,15 @@ repositories {
maven {
url = uri("https://repo1.maven.org/maven2/")
}
maven { url = uri("https://jitpack.io") }
}
kotlin {
android()
androidTarget {
jvmToolchain(17)
}
jvm("desktop") {
jvmToolchain(11)
jvmToolchain(17)
}
sourceSets {
val commonMain by getting {
@ -39,11 +42,19 @@ kotlin {
api(compose.materialIconsExtended)
// ViewModel & Navigation
api("moe.tlaster:precompose:1.3.15")
val precomposeVersion = "1.5.7"
api("moe.tlaster:precompose:$precomposeVersion")
api("moe.tlaster:precompose-viewmodel:$precomposeVersion")
api("moe.tlaster:precompose-koin:$precomposeVersion")
api("io.insert-koin:koin-core:3.5.0")
api("io.insert-koin:koin-compose:1.1.0")
// JWT
implementation("io.github.nefilim.kjwt:kjwt-core:0.9.0")
// Google
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1")
implementation("androidx.compose.material3:material3:1.1.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
implementation("androidx.compose.material3:material3:1.1.2")
implementation("androidx.compose.material:material-icons-extended:1.4.3")
val dataStoreVersion = "1.1.0-alpha04"
@ -93,6 +104,17 @@ kotlin {
implementation("io.ktor:ktor-client-json-jvm:$ktorVersion")
implementation("io.ktor:ktor-client-okhttp:$ktorVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
implementation("org.jetbrains.skiko:skiko-android:0.7.80")
// Lets-Plot Kotlin API
implementation("org.jetbrains.lets-plot:lets-plot-kotlin-kernel:4.4.3")
// Lets-Plot Multiplatform
implementation("org.jetbrains.lets-plot:lets-plot-common:4.0.1")
// Lets-Plot Skia Frontend
implementation("org.jetbrains.lets-plot:lets-plot-compose:1.0.0")
}
}
val androidUnitTest by getting {
@ -107,6 +129,16 @@ kotlin {
api(compose.material3)
// https://mvnrepository.com/artifact/ch.qos.logback/logback-classic
implementation("ch.qos.logback:logback-classic:1.4.11")
// Lets-Plot Kotlin API
implementation("org.jetbrains.lets-plot:lets-plot-kotlin-kernel:4.4.3")
// Lets-Plot Multiplatform
implementation("org.jetbrains.lets-plot:lets-plot-common:4.0.1")
implementation("org.jetbrains.lets-plot:platf-awt:4.0.1")
// Lets-Plot Skia Frontend
implementation("org.jetbrains.lets-plot:lets-plot-compose:1.0.0")
}
}
val desktopTest by getting
@ -117,11 +149,11 @@ android {
compileSdk = 33
sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml")
defaultConfig {
minSdk = 24
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
minSdk = 28
}
namespace = "technology.iatlas.spaceup.common"
}
}
dependencies {
implementation("io.ktor:ktor-client-logging-jvm:2.3.4")
implementation("androidx.compose.ui:ui-geometry-android:1.5.4")
}

View File

@ -8,6 +8,10 @@ actual fun getPlatformName(): String {
}
@Composable
actual fun openInBrowser(domain: String) {
actual fun OpenInBrowser(domain: String) {
LocalUriHandler.current.openUri(domain)
}
@Composable
actual fun notify(title: String, body: String, type: String) {
}

View File

@ -6,11 +6,9 @@ import io.ktor.client.plugins.*
import io.ktor.client.plugins.auth.*
import io.ktor.client.plugins.auth.providers.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.*
import io.ktor.serialization.gson.*
import io.ktor.serialization.kotlinx.json.*
actual fun httpClient(bearerToken: String): HttpClient {
actual fun httpClient(token: String): HttpClient {
return HttpClient(Android) {
/*install(Logging) {
logger = Logger.DEFAULT
@ -22,12 +20,13 @@ actual fun httpClient(bearerToken: String): HttpClient {
install(Auth) {
bearer {
loadTokens {
BearerTokens(bearerToken, "")
BearerTokens(token, "")
}
}
}
install(HttpTimeout) {
requestTimeoutMillis = 20000 // Webbackend list, Get domains take rather long
// 60 secs
requestTimeoutMillis = 60000 // Webbackend list, Get domains take rather long
}
}
}

View File

@ -1,94 +1,75 @@
package technology.iatlas.spaceup.common
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
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.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Divider
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import com.russhwolf.settings.Settings
import com.russhwolf.settings.set
import io.github.nefilim.kjwt.JWT
import moe.tlaster.precompose.navigation.NavHost
import moe.tlaster.precompose.navigation.Navigator
import moe.tlaster.precompose.navigation.RouteBuilder
import moe.tlaster.precompose.navigation.rememberNavigator
import moe.tlaster.precompose.navigation.transition.NavTransition
import moe.tlaster.precompose.ui.LocalViewModelStoreOwner
import moe.tlaster.precompose.ui.viewModel
import moe.tlaster.precompose.viewmodel.ViewModelStoreOwner
import technology.iatlas.spaceup.common.components.Drawer
import technology.iatlas.spaceup.common.model.Routes
import technology.iatlas.spaceup.common.model.SettingsConstants
import technology.iatlas.spaceup.common.pages.DomainsView
import technology.iatlas.spaceup.common.theme.AppTheme
import technology.iatlas.spaceup.common.viewmodel.ServerViewModel
import technology.iatlas.spaceup.common.views.Home
import technology.iatlas.spaceup.common.views.HomeView
import technology.iatlas.spaceup.common.views.Login
import technology.iatlas.spaceup.common.views.SettingsView
import java.util.*
@Composable
fun App(useDarkTheme: Boolean = isSystemInDarkTheme()) {
AppTheme(useDarkTheme = useDarkTheme) {
val navigator = rememberNavigator()
val viewModelStoreOwner = LocalViewModelStoreOwner.current
val serverViewModel = viewModel(ServerViewModel::class) {
ServerViewModel()
val settings = Settings()
val accesstoken = settings.getString(SettingsConstants.ACCESS_TOKEN.toString(), "")
var startPath by remember { mutableStateOf(Routes.LOGIN.path) }
var isValidJWT = false
JWT.decode(accesstoken).tap { d ->
d.expiresAt().tap { isValidJWT = it.isAfter(Date().toInstant()) }
}
startPath = if(isValidJWT) Routes.HOME.path else Routes.LOGIN.path
NavHost(
navigator = navigator,
initialRoute = if(serverViewModel.token.accessToken.isNotEmpty()) Routes.HOME.path else Routes.LOGIN.path
initialRoute = startPath
) {
scene(route = Routes.LOGIN.path, navigation = navigator, viewModelStoreOwner = viewModelStoreOwner) {
scene(route = Routes.LOGIN.path, navigation = navigator) {
Login(it)
}
scene(route = Routes.LOGOUT.path, navigation = navigator, viewModelStoreOwner = viewModelStoreOwner) {
scene(route = Routes.LOGOUT.path, navigation = navigator) {
settings[SettingsConstants.ACCESS_TOKEN.toString()] = ""
Login(it)
}
sceneWithDrawer(route = Routes.HOME.path, navigation = navigator, viewModelStoreOwner = viewModelStoreOwner) {
Home(it)
sceneWithDrawer(route = Routes.SETTINGS.path, navigation = navigator, canGoBack = true) {
SettingsView()
}
sceneWithDrawer(route = Routes.SETTINGS.path, navigation = navigator, viewModelStoreOwner = viewModelStoreOwner) {
SettingsView(it)
sceneWithDrawer(
route = Routes.HOME.path, navigation = navigator, canGoBack = false
) {
HomeView()
}
sceneWithDrawer(
route = Routes.DOMAINS.path, navigation = navigator, canGoBack = true
) {
DomainsView()
}
}
}
@ -96,185 +77,32 @@ fun App(useDarkTheme: Boolean = isSystemInDarkTheme()) {
fun RouteBuilder.scene(
navigation: Navigator,
viewModelStoreOwner: ViewModelStoreOwner,
route: String,
deepLinks: List<String> = emptyList(),
navTransition: NavTransition? = null,
content: @Composable (navigation: Navigator) -> Unit,
) {
scene(route = route, deepLinks = deepLinks, navTransition = navTransition) {
CompositionLocalProvider(
LocalViewModelStoreOwner provides viewModelStoreOwner,
) {
content(navigation)
}
content(navigation)
}
}
fun RouteBuilder.sceneWithDrawer(
navigation: Navigator,
viewModelStoreOwner: ViewModelStoreOwner,
route: String,
deepLinks: List<String> = emptyList(),
navTransition: NavTransition? = null,
canGoBack: Boolean,
content: @Composable (navigation: Navigator) -> Unit,
) {
) {
scene(route = route, deepLinks = deepLinks, navTransition = navTransition) {
CompositionLocalProvider(
LocalViewModelStoreOwner provides viewModelStoreOwner,
) {
Drawer(navigation) {
content(navigation)
}
Drawer(navigation, canGoBack) {
content(navigation)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Drawer(
navigation: Navigator,
content: @Composable (navigation: Navigator) -> Unit
) {
val coroutine = rememberCoroutineScope()
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val serverViewModel = viewModel(ServerViewModel::class) {
ServerViewModel()
}
val drawerList = Routes.values().toList()
ModalNavigationDrawer(
content = {
Scaffold(
//modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
modifier = Modifier
.background(
alpha = 1.0f,
brush = Brush.horizontalGradient(
colors = listOf(
Color.Gray,
Color( 0, 128, 128), // Teal
Color.Gray
),
//startX = 10.0f,
//endX = 20.0f
)
)
.padding(0.dp),
colors = TopAppBarDefaults.smallTopAppBarColors(containerColor = Color.Transparent),
title = { Text("NextUI") },
navigationIcon = {
IconButton(onClick = {
coroutine.launch {
drawerState.open()
}
}) {
Icon(Icons.Default.Menu, contentDescription = "Menu")
}
},
actions = {
IconButton(onClick = {
}) {
Icon(Icons.Default.Search, contentDescription = "Search")
}
}
)
}
) {
Column(modifier = Modifier
//.fillMaxSize()
.padding(top = it.calculateTopPadding())
) {
content.invoke(navigation)
}
}
},
drawerState = drawerState,
drawerContent = {
Column(
modifier = Modifier
.width(320.dp)
.background(MaterialTheme.colorScheme.onPrimary)
) {
if(serverViewModel.token.accessToken.isNotEmpty()) {
Card(
shape = NavShape(0.dp, 1.0f),
elevation = CardDefaults.elevatedCardElevation(),
modifier = Modifier
.height(30.dp)
.align(Alignment.CenterHorizontally)
.fillMaxWidth()
) {
Text(
serverViewModel.serverUrl,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
}
Card(
shape = NavShape(0.dp, 1.0f),
elevation = CardDefaults.elevatedCardElevation(),
modifier = Modifier
.height(30.dp)
.align(Alignment.CenterHorizontally)
.fillMaxWidth()
) {
Text(
serverViewModel.expiresAsString,
color = MaterialTheme.colorScheme.error,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
}
}
LazyColumn(
modifier = Modifier
.width(240.dp)
.padding(start = 24.dp, top = 8.dp)
.fillMaxHeight()
//.padding(start = 24.dp, top = 48.dp),
) {
drawerList.forEach {
val route = it.path
val menuItem = it.title
val drawerBehavior = it.drawerBehavior
it.apply {
if (drawerBehavior.isVisible) {
item {
Spacer(Modifier.height(4.dp))
TextButton(
onClick = {
try {
navigation.navigate(route)
} catch (ex: IllegalStateException) {
// NOP Do nothing currently if there is no route
}
}
) {
Text(
menuItem,
fontWeight = FontWeight.Bold
)
}
if (drawerBehavior.hasAfterDivider) {
Divider()
}
}
}
}
}
}
}
}
)
}
class NavShape(
private val widthOffset: Dp,
private val scale: Float

View File

@ -1,18 +1,21 @@
package technology.iatlas.spaceup.common.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.AlertDialog
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun Alert(openDialog: MutableState<Boolean>, errorMsg: String) {
fun ErrorAlert(openDialog: MutableState<Boolean>, errorMsg: String) {
val scroll = rememberScrollState(0)
AlertDialog(
backgroundColor = MaterialTheme.colorScheme.errorContainer,
onDismissRequest = {
openDialog.value = false
},
@ -32,7 +35,11 @@ fun Alert(openDialog: MutableState<Boolean>, errorMsg: String) {
val baseMsg = "An error occurred: \n%s"
val msg = if(errorMsg.isEmpty()) baseMsg.replace(": %s", "")
else baseMsg.replace("%s", errorMsg)
Text(msg)
Text(
text = msg,
modifier = Modifier
.verticalScroll(scroll)
)
},
modifier = Modifier.fillMaxWidth()
)

View File

@ -0,0 +1,207 @@
package technology.iatlas.spaceup.common.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
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.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Divider
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import moe.tlaster.precompose.koin.koinViewModel
import moe.tlaster.precompose.navigation.Navigator
import moe.tlaster.precompose.stateholder.LocalSavedStateHolder
import org.koin.core.parameter.parametersOf
import technology.iatlas.spaceup.common.NavShape
import technology.iatlas.spaceup.common.model.Routes
import technology.iatlas.spaceup.common.viewmodel.ServerViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Drawer(
navigation: Navigator,
canGoBack: Boolean = true,
content: @Composable (navigation: Navigator) -> Unit
) {
val coroutine = rememberCoroutineScope()
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val stateHolder = LocalSavedStateHolder.current
val serverViewModel = koinViewModel(ServerViewModel::class) { parametersOf(stateHolder) }
val serverUrl = serverViewModel.serverUrl
val token = serverViewModel.token.value
val drawerList = Routes.entries
ModalNavigationDrawer(
content = {
Scaffold(
//modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
modifier = Modifier
.background(
alpha = 1.0f,
brush = Brush.horizontalGradient(
colors = listOf(
Color.Gray,
Color(0, 128, 128), // Teal
Color.Gray
),
//startX = 10.0f,
//endX = 20.0f
)
)
.padding(0.dp),
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
title = { Text("NextUI") },
navigationIcon = {
IconButton(onClick = {
coroutine.launch {
if (canGoBack) {
navigation.popBackStack()
} else {
drawerState.open()
}
}
}) {
if (canGoBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
} else {
Icon(Icons.Default.Menu, contentDescription = "Menu")
}
}
},
actions = {
IconButton(onClick = {
}) {
Icon(Icons.Default.Search, contentDescription = "Search")
}
}
)
}
) {
Column(
modifier = Modifier
//.fillMaxSize()
.padding(top = it.calculateTopPadding())
) {
content.invoke(navigation)
}
}
},
drawerState = drawerState,
drawerContent = {
ModalDrawerSheet {
Column(
modifier = Modifier
.width(320.dp)
) {
Card(
shape = NavShape(0.dp, 1.0f),
elevation = CardDefaults.elevatedCardElevation(),
modifier = Modifier
.align(Alignment.CenterHorizontally)
.fillMaxWidth()
) {
Text(
serverUrl,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
}
if (token.isNotEmpty()) {
Card(
shape = NavShape(0.dp, 1.0f),
elevation = CardDefaults.elevatedCardElevation(),
modifier = Modifier
.height(30.dp)
.align(Alignment.CenterHorizontally)
.fillMaxWidth()
) {
Text(
serverViewModel.expiresAsString,
color = MaterialTheme.colorScheme.error,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(10.dp, 0.dp)
)
}
}
LazyColumn(
modifier = Modifier
.width(240.dp)
.padding(start = 24.dp, top = 8.dp)
.fillMaxHeight()
//.padding(start = 24.dp, top = 48.dp),
) {
drawerList.forEach {
val route = it.path
val menuItem = it.title
val drawerBehavior = it.drawerBehavior
it.apply {
if (drawerBehavior.isVisible) {
item {
Spacer(Modifier.height(4.dp))
TextButton(
onClick = {
try {
navigation.navigate(route)
} catch (ex: IllegalStateException) {
// NOP Do nothing currently if there is no route
}
}
) {
Text(
menuItem,
fontWeight = FontWeight.Bold
)
}
if (drawerBehavior.hasAfterDivider) {
Divider()
}
}
}
}
}
}
}
}
}
)
}

View File

@ -0,0 +1,3 @@
package technology.iatlas.spaceup.common.model
data class Hostname(val hostname: String)

View File

@ -5,7 +5,7 @@ enum class Routes(
val path: String,
val drawerBehavior: DrawerBehavior = DrawerBehavior(),
) {
HOME("Home", "/home"),
HOME("Home", "/home", DrawerBehavior(isVisible = false)),
DOMAINS("Domains", "/domains"),
SERVICES("Services", "/services"),
WEBBACKENDS("Web backends", "/webbackends"),
@ -14,16 +14,6 @@ enum class Routes(
ABOUT("About", "/about", DrawerBehavior(hasAfterDivider = true)),
LOGOUT("Logout", "/logout"),
LOGIN("Login", "/login", DrawerBehavior(isVisible = false)),
// TODO DELETE me if test is successful
// For testing Drawer behavior
fake1("Logout", "/a"),
fake2("Logout", "/b"),
fake3("Logout", "/c"),
fake4("Logout", "/d"),
fake5("Logout", "/e"),
fake6("Logout", "/f"),
fake7("Logout", "/g"),
}
data class DrawerBehavior(

View File

@ -10,7 +10,8 @@ enum class SettingsConstants(
REMEMBER_CREDENTIALS("rememberCredentials", "Remember Credentials", "boolean"),
USERNAME("username", "Username", "string"),
PASSWORD("password", "Password", "string"),
AUTO_LOGIN("autoLogin", "Auto Login", "boolean");
AUTO_LOGIN("autoLogin", "Auto Login", "boolean"),
ACCESS_TOKEN("token", "", "string");
override fun toString(): String {
return naming

View File

@ -0,0 +1,280 @@
package technology.iatlas.spaceup.common.pages
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlin.math.atan2
import kotlin.math.min
@Composable
fun DiskPlot() {
// TODO use real data from API
Column(
modifier = Modifier
.fillMaxWidth()
) {
Card(
modifier = Modifier
.fillMaxWidth()
) {
Text(
modifier = Modifier
.align(Alignment.CenterHorizontally),
text = "Disk Plot"
)
DonutChart(
modifier = Modifier
.align(Alignment.CenterHorizontally),
colors = listOf(MaterialTheme.colorScheme.inversePrimary, MaterialTheme.colorScheme.secondary),
inputValues = listOf(10f, 20f)
)
}
}
}
/**
* Component for creating Donut Chart
* Slices are painted clockwise
* e.g. 1st input value starts from top to the right, etc
*/
private const val animationDuration = 800
private const val chartDegrees = 360f
private const val emptyIndex = -1
private val defaultSliceWidth = 12.dp
private val defaultSlicePadding = 5.dp
private val defaultSliceClickPadding = 8.dp
@Composable
internal fun DonutChart(
modifier: Modifier = Modifier,
colors: List<Color>,
inputValues: List<Float>,
sliceWidthDp: Dp = defaultSliceWidth,
slicePaddingDp: Dp = defaultSlicePadding,
sliceClickPaddingDp: Dp = defaultSliceClickPadding,
animated: Boolean = true
) {
val textMeasurer = rememberTextMeasurer()
val maxDiskColor = MaterialTheme.colorScheme.primary
val usedDiskSpaceColor = MaterialTheme.colorScheme.secondary
val freeDiskSpaceColor = MaterialTheme.colorScheme.inverseSurface
assert(inputValues.isNotEmpty() && inputValues.size == colors.size) {
"Input values count must be equal to colors size"
}
// disk text
val text = buildAnnotatedString {
// Max disk space
withStyle(
style = SpanStyle(
color = maxDiskColor,
fontSize = 22.sp
)
) {
append("Max disk space:\n")
}
withStyle(
style = SpanStyle(
color = maxDiskColor,
fontSize = 18.sp
)
) {
append("10GB\n")
append("\n")
}
// Used disk space
withStyle(
style = SpanStyle(
color = usedDiskSpaceColor,
fontSize = 22.sp
)
) {
append("Used disk space:\n")
}
withStyle(
style = SpanStyle(
color = usedDiskSpaceColor,
fontSize = 18.sp
)
) {
append("8GB\n")
}
// Free disk space
withStyle(
style = SpanStyle(
color = freeDiskSpaceColor,
fontSize = 22.sp
)
) {
append("Free disk space: \n")
}
withStyle(
style = SpanStyle(
color = freeDiskSpaceColor,
fontSize = 18.sp
)
) {
append("2GB\n")
}
}
// calculate each input percentage
val proportions = inputValues.toPercent()
// calculate each input slice degrees
val angleProgress = proportions.map { prop ->
chartDegrees * prop / 100
}
// start drawing clockwise (top to right)
var startAngle = 270f
// used for animating each slice
val pathPortion = remember {
Animatable(initialValue = 0f)
}
// clicked slice in chart
var clickedItemIndex by remember {
mutableStateOf(emptyIndex)
}
// calculate each slice end point in degrees, for handling click position
val progressSize = mutableListOf<Float>()
LaunchedEffect(angleProgress) {
progressSize.add(angleProgress.first())
for (x in 1 until angleProgress.size) {
progressSize.add(angleProgress[x] + progressSize[x - 1])
}
}
val density = LocalDensity.current
//convert dp values to pixels
val sliceWidthPx = with(density) { sliceWidthDp.toPx() }
val slicePaddingPx = with(density) { slicePaddingDp.toPx() }
val sliceClickPaddingPx = with(density) { sliceClickPaddingDp.toPx() }
// slice width when clicked
val selectedSliceWidth = sliceWidthPx + sliceClickPaddingPx
// animate chart slices on composition
LaunchedEffect(inputValues) {
pathPortion.animateTo(1f, animationSpec = tween(if (animated) animationDuration else 0))
}
BoxWithConstraints(modifier = modifier, contentAlignment = Alignment.Center) {
val canvasSize = min(constraints.maxWidth, constraints.maxHeight)
val padding = canvasSize * slicePaddingPx / 100f
val size = Size(canvasSize.toFloat() - padding, canvasSize.toFloat() - padding)
val canvasSizeDp = with(density) { canvasSize.toDp() }
Canvas(
modifier = Modifier
.size(canvasSizeDp)
.align(Alignment.Center)
/*.pointerInput(inputValues) {
detectTapGestures { offset ->
val clickedAngle = touchPointToAngle(
width = canvasSize.toFloat(),
height = canvasSize.toFloat(),
touchX = offset.x,
touchY = offset.y,
chartDegrees = chartDegrees
)
progressSize.forEachIndexed { index, item ->
if (clickedAngle <= item) {
clickedItemIndex = index
return@detectTapGestures
}
}
}
},*/
) {
angleProgress.forEachIndexed { index, angle ->
drawArc(
color = colors[index],
startAngle = startAngle,
sweepAngle = angle * pathPortion.value,
useCenter = false,
size = size,
style = Stroke(width = if (clickedItemIndex == index) selectedSliceWidth else sliceWidthPx),
topLeft = Offset(padding / 2, padding / 2),
)
startAngle += angle
}
val canvasWidth = size.width
val canvasHeight = size.height
val textLayoutResult = textMeasurer.measure(text)
val textSize = textLayoutResult.size
drawText(
textMeasurer = textMeasurer,
text = text,
topLeft = Offset(
(canvasWidth - textSize.width) / 2f,
(canvasHeight - textSize.height) / 2f
)
)
}
}
}
internal fun List<Float>.toPercent(): List<Float> {
return this.map { item ->
item * 100 / this.sum()
}
}
internal fun touchPointToAngle(
width: Float,
height: Float,
touchX: Float,
touchY: Float,
chartDegrees: Float
): Double {
val x = touchX - (width * 0.5f)
val y = touchY - (height * 0.5f)
var angle = Math.toDegrees(atan2(y.toDouble(), x.toDouble()) + Math.PI / 2)
angle = if (angle < 0) angle + chartDegrees else angle
return angle
}

View File

@ -1,4 +1,4 @@
package technology.iatlas.spaceup.common.views
package technology.iatlas.spaceup.common.pages
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.layout.Arrangement
@ -32,13 +32,13 @@ 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.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.russhwolf.settings.Settings
import io.github.oshai.kotlinlogging.KotlinLogging
import io.ktor.client.call.*
import io.ktor.client.request.*
@ -46,29 +46,39 @@ import io.ktor.http.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import moe.tlaster.precompose.navigation.Navigator
import moe.tlaster.precompose.ui.viewModel
import technology.iatlas.spaceup.common.components.Alert
import moe.tlaster.precompose.koin.koinViewModel
import moe.tlaster.precompose.stateholder.LocalSavedStateHolder
import technology.iatlas.spaceup.common.OpenInBrowser
import technology.iatlas.spaceup.common.components.ErrorAlert
import technology.iatlas.spaceup.common.components.FullscreenCircularLoader
import technology.iatlas.spaceup.common.model.Domain
import technology.iatlas.spaceup.common.openInBrowser
import technology.iatlas.spaceup.common.model.SettingsConstants
import technology.iatlas.spaceup.common.util.httpClient
import technology.iatlas.spaceup.common.viewmodel.ServerViewModel
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun Home(navigator: Navigator) {
fun DomainsView() {
val logger = KotlinLogging.logger {}
val coroutineScope = rememberCoroutineScope()
val cached = remember { mutableStateOf(false) }
val settings = Settings()
val cached = remember { mutableStateOf(true) }
var isLoading by remember { mutableStateOf(false) }
val domains = remember { mutableStateListOf<Domain>() }
val openDialog = remember { mutableStateOf(false) }
val errorMsg = remember { mutableStateOf("") }
var isEnabled by remember { mutableStateOf(true) }
val serverViewModel = viewModel(ServerViewModel::class) {
ServerViewModel()
}
val stateHolder = LocalSavedStateHolder.current
val serverViewModel = koinViewModel(ServerViewModel::class) { org.koin.core.parameter.parametersOf(stateHolder) }
val serverUrl = serverViewModel.serverUrl
val client = httpClient(settings.getString(SettingsConstants.ACCESS_TOKEN.toString(), ""))
// Create grid view layout
Column(
modifier = Modifier.fillMaxSize()
@ -81,8 +91,8 @@ fun Home(navigator: Navigator) {
isLoading = true
isEnabled = false
try {
val response = serverViewModel.client()
.get("${serverViewModel.serverUrl}/api/domain/list?cached=${cached.value}")
val response = client
.get("${serverUrl}/api/domain/list?cached=${cached.value}")
if (response.status == HttpStatusCode.OK) {
val domainList = response.body<List<Domain>>()
logger.info { "Received domains: $domainList" }
@ -93,7 +103,7 @@ fun Home(navigator: Navigator) {
}
} else {
// Show error
errorMsg.value = response.body()
errorMsg.value = response.status.description
openDialog.value = true
}
} catch (ex: Exception) {
@ -122,7 +132,7 @@ fun Home(navigator: Navigator) {
}
if (openDialog.value) {
Alert(openDialog, serverViewModel.serverUrl)
ErrorAlert(openDialog, errorMsg.value)
}
if (!cached.value && isLoading) {
@ -135,10 +145,8 @@ fun Home(navigator: Navigator) {
LaunchedEffect(Unit) {
try {
// TODO move this to domainViewModel
// "Authorization": 'Bearer $jwt'
val response = serverViewModel.client()
.get("${serverViewModel.serverUrl}/api/domain/list?cached=true")
val response = client
.get("${serverUrl}/api/domain/list?cached=${cached.value}")
if (response.status == HttpStatusCode.OK) {
response.body<List<Domain>>().forEach {
if (!domains.contains(it)) {
@ -147,7 +155,7 @@ fun Home(navigator: Navigator) {
}
} else {
// Show error
errorMsg.value = response.body()
errorMsg.value = response.status.description
openDialog.value = true
}
} catch (ex: Exception) {
@ -172,7 +180,7 @@ fun MessageList(domains: List<Domain>) {
items(domains.size) { index ->
Card(
modifier = Modifier
.height(60.dp)
.height(35.dp)
.padding(4.dp)
.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
@ -182,53 +190,44 @@ fun MessageList(domains: List<Domain>) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Row(
horizontalArrangement = Arrangement.Start,
modifier = Modifier
.fillMaxWidth()
Text(
modifier = Modifier.padding(start = 12.dp),
text = domains[index].url,
fontWeight = FontWeight.Bold
)
IconButton(
modifier = Modifier.align(Alignment.CenterVertically),
onClick = {
expanded.value = !expanded.value
}
) {
Text(
text = domains[index].url,
fontWeight = FontWeight.Bold
)
}
Row(
horizontalArrangement = Arrangement.End,
//modifier = Modifier.fillMaxWidth()
) {
IconButton(
onClick = {
expanded.value = !expanded.value
}
Column(
horizontalAlignment = Alignment.End
) {
Column(
horizontalAlignment = Alignment.End
Icon(
imageVector = Icons.Default.MoreVert,
"More"
)
DropdownMenu(
expanded = expanded.value,
onDismissRequest = { expanded.value = false },
) {
Icon(
imageVector = Icons.Default.MoreVert,
"More"
)
DropdownMenu(
expanded = expanded.value,
onDismissRequest = { expanded.value = false },
) {
DropdownMenuItem(
text = { Text("Open") },
onClick = {
domain = domains[index].url
})
Divider()
DropdownMenuItem(
text = {
Text(
"Delete",
style = TextStyle(color = MaterialTheme.colorScheme.error)
)
},
onClick = {
// Delete Domain with Alert warning
})
}
DropdownMenuItem(
text = { Text("Open") },
onClick = {
domain = domains[index].url
})
Divider()
DropdownMenuItem(
text = {
Text(
"Delete",
style = TextStyle(color = MaterialTheme.colorScheme.error)
)
},
onClick = {
// Delete Domain with Alert warning
})
}
}
}
@ -238,7 +237,7 @@ fun MessageList(domains: List<Domain>) {
}
if(domain.isNotEmpty()) {
openInBrowser("https://${domain}").also {
OpenInBrowser("https://${domain}").also {
domain = ""
}
}

View File

@ -0,0 +1,188 @@
package technology.iatlas.spaceup.common.pages
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Api
import androidx.compose.material.icons.filled.CloudCircle
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import com.russhwolf.settings.Settings
import com.russhwolf.settings.set
import io.github.oshai.kotlinlogging.KotlinLogging
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.util.*
import moe.tlaster.precompose.koin.koinViewModel
import moe.tlaster.precompose.navigation.rememberNavigator
import moe.tlaster.precompose.stateholder.LocalSavedStateHolder
import technology.iatlas.spaceup.common.model.Hostname
import technology.iatlas.spaceup.common.model.Routes
import technology.iatlas.spaceup.common.model.SettingsConstants
import technology.iatlas.spaceup.common.util.httpClient
import technology.iatlas.spaceup.common.viewmodel.ServerViewModel
import java.net.ConnectException
@OptIn(InternalAPI::class)
@Composable
fun Server() {
val logger = KotlinLogging.logger { }
val settings = Settings()
val navigator = rememberNavigator()
val serverVersion = remember { mutableStateOf("") }
val hostname = remember { mutableStateOf("") }
val stateHolder = LocalSavedStateHolder.current
val serverViewModel = koinViewModel(ServerViewModel::class) { org.koin.core.parameter.parametersOf(stateHolder) }
val serverUrl = serverViewModel.serverUrl
Column(
modifier = Modifier.padding(vertical = 8.dp),
verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Card(
modifier = Modifier
.height(35.dp)
.padding(4.dp)
.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
) {
Row(
modifier = Modifier.align(Alignment.CenterHorizontally)
) {
TextLineIcon(
text = "Server Version: ${serverVersion.value}",
icon = Icons.Default.Api,
iconTint = MaterialTheme.colorScheme.secondary
)
}
}
Card(
modifier = Modifier
.height(35.dp)
.padding(4.dp)
.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
) {
Row(
modifier = Modifier.align(Alignment.CenterHorizontally)
) {
TextLineIcon(
text = "Hostname: ${hostname.value}",
icon = Icons.Default.CloudCircle,
iconTint = MaterialTheme.colorScheme.secondary
)
}
}
}
LaunchedEffect(Unit) {
val client = httpClient(settings.getString(SettingsConstants.ACCESS_TOKEN.toString(), ""))
try {
val responseVersion = client
.get("${serverUrl}/api/system/version")
val content = responseVersion.body<String>()
logger.info { "Version response: $responseVersion" }
if(responseVersion.status == HttpStatusCode.OK) {
logger.info { "Server version: $content" }
serverVersion.value = content
} else {
logger.error { content }
}
val responseHostname = client
.get("${serverUrl}/api/system/hostname")
val hostnameBody = responseHostname.body<Hostname>()
if(responseVersion.status == HttpStatusCode.OK) {
logger.info { "Hostname: $hostnameBody" }
hostname.value = hostnameBody.hostname
} else {
logger.error { hostnameBody }
}
} catch (ex: ConnectException) {
// unable to connect to host
// clear server from history and remove JWT
settings[SettingsConstants.SERVER_URL.toString()] = ""
settings[SettingsConstants.ACCESS_TOKEN.toString()] = ""
// logout
navigator.navigate(Routes.LOGIN.path)
}
}
}
@Composable
fun TextLineIcon(
text: String,
icon: ImageVector,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
fontSize: TextUnit = TextUnit.Unspecified,
fontWeight: FontWeight? = null,
iconRightPadding: Dp = 0.dp,
iconLine: Int = 0,
iconTint: Color = MaterialTheme.colorScheme.secondary
// etc
) {
val painter = rememberVectorPainter(image = icon)
var lineTop = 0f
var lineBottom = 0f
var lineLeft = 0f
with(LocalDensity.current) {
val imageSize = Size(icon.defaultWidth.toPx(), icon.defaultHeight.toPx())
val rightPadding = iconRightPadding.toPx()
Text(
text = text,
color = color,
fontSize = fontSize,
fontWeight = fontWeight,
onTextLayout = { layoutResult ->
val nbLines = layoutResult.lineCount
if (nbLines > iconLine) {
lineTop = layoutResult.getLineTop(iconLine)
lineBottom = layoutResult.getLineBottom(iconLine)
lineLeft = layoutResult.getLineLeft(iconLine)
}
},
modifier = modifier.drawBehind {
with(painter) {
translate(
left = lineLeft - imageSize.width - rightPadding,
top = lineTop + (lineBottom - lineTop) / 2 - imageSize.height / 2,
) {
//draw(painter.intrinsicSize, colorFilter = ColorFilter.tint(iconTint))
}
}
}
)
}
}

View File

@ -1,4 +1,11 @@
package technology.iatlas.spaceup.common
import androidx.compose.runtime.Composable
expect fun getPlatformName(): String
expect fun openInBrowser(domain: String)
@Composable
expect fun OpenInBrowser(domain: String)
@Composable
expect fun notify(title: String, body: String, type: String = "INFO")

View File

@ -2,9 +2,15 @@ package technology.iatlas.spaceup.common.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import moe.tlaster.precompose.PreComposeApp
import moe.tlaster.precompose.stateholder.SavedStateHolder
import org.koin.compose.KoinApplication
import org.koin.dsl.module
import technology.iatlas.spaceup.common.viewmodel.AuthenticationViewModel
import technology.iatlas.spaceup.common.viewmodel.ServerViewModel
private val LightColors = lightColorScheme(
@ -75,16 +81,31 @@ private val DarkColors = darkColorScheme(
@Composable
fun AppTheme(
useDarkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
appcontent: @Composable () -> Unit
) {
val colors = if (!useDarkTheme) {
LightColors
} else {
DarkColors
}
val colors = if (!useDarkTheme) {
LightColors
} else {
DarkColors
}
MaterialTheme(
colorScheme = colors,
content = content
)
MaterialTheme(
colorScheme = colors,
content = {
PreComposeApp {
KoinApplication(
application = {
modules(
module {
factory { (savedStateHolder: SavedStateHolder) -> ServerViewModel(savedStateHolder) }
single { AuthenticationViewModel() }
}
)
}
) {
appcontent()
}
}
}
)
}

View File

@ -0,0 +1,6 @@
package technology.iatlas.spaceup.common.util
object Helper {
fun getSystemProfile(): String = System.getProperty("profile") ?: ""
}

View File

@ -2,4 +2,4 @@ package technology.iatlas.spaceup.common.util
import io.ktor.client.*
expect fun httpClient(bearerToken: String = ""): HttpClient
expect fun httpClient(token: String): HttpClient

View File

@ -6,13 +6,11 @@ import androidx.compose.runtime.setValue
import com.google.gson.annotations.SerializedName
import io.github.oshai.kotlinlogging.KotlinLogging
import io.ktor.client.call.*
import io.ktor.client.plugins.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import moe.tlaster.precompose.navigation.Navigator
import moe.tlaster.precompose.viewmodel.ViewModel
import technology.iatlas.spaceup.common.model.Authentication
import technology.iatlas.spaceup.common.util.Helper.getSystemProfile
import technology.iatlas.spaceup.common.util.httpClient
class AuthenticationViewModel : ViewModel() {
@ -21,22 +19,23 @@ class AuthenticationViewModel : ViewModel() {
var username by mutableStateOf("")
var password by mutableStateOf("")
suspend fun login(navigator: Navigator, serverViewModel: ServerViewModel) {
suspend fun login(serverUrl: String): Token {
val authentication = Authentication(username.trim(), password.trim())
val profile = getSystemProfile()
logger.info {
"Authenticate with $authentication to ${serverViewModel.serverUrl}"
if(profile.lowercase().contains("dev")) {
"Authenticate with $authentication to $serverUrl"
} else {
"Authenticate with ${authentication.password.replace(Regex("."), "*")} to $serverUrl"
}
}
if(username.isNotEmpty() && password.isNotEmpty()) {
val response = httpClient().post("${serverViewModel.serverUrl}/login") {
val response = httpClient("").post("$serverUrl/login") {
contentType(ContentType.Application.Json)
timeout {
}
setBody(authentication)
}
if(response.status == HttpStatusCode.OK) {
serverViewModel.token = response.body() as Token
navigator.navigate("/home")
return response.body<Token>()
} else {
throw Exception("Something is wrong: Code ${response.status.value} Message: ${response.body<String>()}")
}

View File

@ -6,26 +6,25 @@ import androidx.compose.runtime.setValue
import com.russhwolf.settings.Settings
import com.russhwolf.settings.get
import io.github.oshai.kotlinlogging.KotlinLogging
import io.ktor.client.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import moe.tlaster.precompose.stateholder.SavedStateHolder
import moe.tlaster.precompose.viewmodel.ViewModel
import technology.iatlas.spaceup.common.model.SettingsConstants
import technology.iatlas.spaceup.common.util.httpClient
import kotlin.time.Duration.Companion.seconds
class ServerViewModel : ViewModel() {
class ServerViewModel(savedStateHolder: SavedStateHolder) : ViewModel() {
private val logger = KotlinLogging.logger { }
// Contains base url
var serverUrl by mutableStateOf("https://")
var serverUrl by mutableStateOf("")
// Contains the JWT for authentication
var token by mutableStateOf(Token("", "", 0))
private val _token = MutableStateFlow(savedStateHolder.consumeRestored("token") as String? ?: "")
val token: StateFlow<String> = _token
// For showing how long the JWT is valid
var expiresAsString by mutableStateOf("")
@ -33,12 +32,21 @@ class ServerViewModel : ViewModel() {
init {
val settings = Settings()
serverUrl = settings[SettingsConstants.SERVER_URL.toString()] ?: ""
savedStateHolder.registerProvider("token") {
token.value
}
}
fun updateToken(token: String) {
logger.info { "Update token: $token" }
val successful = _token.tryEmit(token)
}
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun checkExpiresIn() = runBlocking {
CoroutineScope(Dispatchers.IO.limitedParallelism(1)).launch {
while (true) {
/*while (true) {
if(token.expiresIn <= 0) {
expiresAsString = "Session timed out." // TODO use it for re-login automatically
delay(100)
@ -48,9 +56,10 @@ class ServerViewModel : ViewModel() {
logger.trace { "token expires in ${token.expiresIn.seconds}" }
// 1 second
delay(1000) // somehow more accurate than Kotlin delay(...)
token.expiresIn = token.expiresIn - 1
token.expiresIn -= 1
}
}
}*/
}
}
@ -59,12 +68,7 @@ class ServerViewModel : ViewModel() {
*
* @return is valid JWT
*/
fun validToken(): Boolean {
// TODO me
return true
}
fun client(): HttpClient {
return httpClient(token.accessToken)
suspend fun validToken(): Boolean {
return false //token.expiresIn >= 0
}
}

View File

@ -0,0 +1,11 @@
package technology.iatlas.spaceup.common.views
import androidx.compose.runtime.Composable
import technology.iatlas.spaceup.common.pages.DiskPlot
import technology.iatlas.spaceup.common.pages.Server
@Composable
fun HomeView() {
Server()
DiskPlot()
}

View File

@ -47,16 +47,21 @@ import com.russhwolf.settings.Settings
import com.russhwolf.settings.get
import com.russhwolf.settings.set
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import moe.tlaster.precompose.koin.koinViewModel
import moe.tlaster.precompose.navigation.Navigator
import moe.tlaster.precompose.ui.viewModel
import technology.iatlas.spaceup.common.components.Alert
import moe.tlaster.precompose.stateholder.LocalSavedStateHolder
import org.koin.core.parameter.parametersOf
import technology.iatlas.spaceup.common.components.ErrorAlert
import technology.iatlas.spaceup.common.components.FullscreenCircularLoader
import technology.iatlas.spaceup.common.model.Routes
import technology.iatlas.spaceup.common.model.SettingsConstants
import technology.iatlas.spaceup.common.viewmodel.AuthenticationViewModel
import technology.iatlas.spaceup.common.viewmodel.ServerViewModel
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class, DelicateCoroutinesApi::class)
@Composable
fun Login(navigator: Navigator) {
/*
@ -84,12 +89,12 @@ fun Login(navigator: Navigator) {
settings.getBoolean(SettingsConstants.REMEMBER_CREDENTIALS.toString(), false)) }
val coroutineScope = rememberCoroutineScope()
val serverViewModel = viewModel(ServerViewModel::class) {
ServerViewModel()
}
val authenticationViewModel = viewModel(AuthenticationViewModel::class) {
AuthenticationViewModel()
}
val stateHolder = LocalSavedStateHolder.current
val serverViewModel = koinViewModel(ServerViewModel::class) { parametersOf(stateHolder) }
val serverUrl = serverViewModel.serverUrl
val authenticationViewModel = koinViewModel(AuthenticationViewModel::class)
Column(
// modifier = Modifier.fillMaxSize(), // To make it center center
@ -105,6 +110,7 @@ fun Login(navigator: Navigator) {
.height(48.dp)
) {
Text(
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(horizontal = 36.dp)
.align(Alignment.CenterHorizontally),
@ -126,19 +132,21 @@ fun Login(navigator: Navigator) {
label = {
Text("SpaceUp-Server")
},
value = serverViewModel.serverUrl,
value = serverUrl,
onValueChange = {
val serverUrl = it.trim()
serverViewModel.serverUrl = serverUrl
val updatedServerUrl = it.trim()
serverViewModel.serverUrl = updatedServerUrl
if(rememberServer) {
val serverConst = SettingsConstants.SERVER_URL.toString()
settings[serverConst] = serverUrl
}
}
)
Row {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
modifier = Modifier.padding(16.dp),
modifier = Modifier.padding(horizontal = 16.dp),
checked = rememberServer,
onCheckedChange = {
rememberServer = it
@ -146,13 +154,15 @@ fun Login(navigator: Navigator) {
val serverConst = SettingsConstants.SERVER_URL.toString()
settings[rememberServerConst] = rememberServer
if(rememberServer) {
settings[serverConst] = serverViewModel.serverUrl
settings[serverConst] = serverUrl
} else {
settings[serverConst] = ""
}
}
)
Text("Remember server?", modifier = Modifier.padding(16.dp))
Text(
color = MaterialTheme.colorScheme.secondary,
text = "Remember server?", modifier = Modifier.padding(start = 16.dp))
}
Spacer(modifier = Modifier.height(15.dp))
OutlinedTextField(
@ -217,9 +227,11 @@ fun Login(navigator: Navigator) {
}
}
)
Row {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
modifier = Modifier.padding(16.dp),
modifier = Modifier.padding(horizontal = 16.dp),
checked = rememberUserPassword,
onCheckedChange = {
rememberUserPassword = it
@ -236,26 +248,34 @@ fun Login(navigator: Navigator) {
}
}
)
Text("Remember Credentials?", modifier = Modifier.padding(16.dp))
Text(
color = MaterialTheme.colorScheme.secondary,
text = "Remember Credentials?", modifier = Modifier.padding(start = 16.dp)
)
}
Row {
val formIsFilled = authenticationViewModel.username.isNotEmpty()
&& authenticationViewModel.password.isNotEmpty() && serverViewModel.serverUrl.isNotEmpty()
&& authenticationViewModel.password.isNotEmpty() && serverUrl.isNotEmpty()
AnimatedVisibility(formIsFilled) {
Button(
onClick = {
isLoading = true
coroutineScope.launch {
logger.info {
"User ${authenticationViewModel.username} is trying to login."
coroutineScope.launch(Dispatchers.IO) {
logger.info {
"User ${authenticationViewModel.username} is trying to login."
}
try {
val token = authenticationViewModel.login(serverUrl)
settings[SettingsConstants.ACCESS_TOKEN.toString()] = token.accessToken
//serverViewModel.updateToken(token.accessToken)
logger.info { "Finished login" }
if(token.username.isNotEmpty()) navigator.navigate("/home")
} catch (ex: Exception) {
openDialog.value = true
errorMsg.value = ex.message ?: "Cannot login!"
}
}
try {
authenticationViewModel.login(navigator, serverViewModel)
} catch (ex: Exception) {
openDialog.value = true
errorMsg.value = ex.message ?: "Cannot login!"
}
}
isLoading = false
}
) {
@ -280,6 +300,11 @@ fun Login(navigator: Navigator) {
Text("Settings")
}
}
if (openDialog.value) {
ErrorAlert(openDialog, errorMsg.value)
}
if(isLoading) FullscreenCircularLoader(isLoading)
}
LaunchedEffect(Unit) {
@ -294,14 +319,9 @@ fun Login(navigator: Navigator) {
serverViewModel.checkExpiresIn()
}
if (openDialog.value) {
Alert(openDialog, errorMsg.value)
}
}
// TODO Make this general usable for other components
@OptIn(ExperimentalComposeUiApi::class)
fun handleKeyEvents(
navigator: Navigator,
keyEvent: KeyEvent, // Get KeyEvents

View File

@ -6,31 +6,28 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import moe.tlaster.precompose.navigation.Navigator
@Composable
fun SettingsView(navigator: Navigator) {
fun SettingsView() {
val settingsList = listOf(
SettingItem("Einstellung 1", "Beschreibung 1", Icons.Filled.Settings, true) {},
SettingItem("Einstellung 2", "Beschreibung 2", Icons.Filled.Info, false) {},
SettingItem("Einstellung 3", "Beschreibung 3", Icons.Filled.Settings, true) {},
SettingItem("Einstellung 4", "Beschreibung 4", Icons.Filled.Info, false) {},
SettingItem("Einstellung 5", "Beschreibung 5", Icons.Filled.Settings, true) {}
SettingItemState("Einstellung 1", "Beschreibung 1", Icons.Filled.Settings, mutableStateOf(false)) { },
SettingItemState("Einstellung 2", "Beschreibung 2", Icons.Filled.Info, mutableStateOf(true)) {},
SettingItemState("Einstellung 3", "Beschreibung 3", Icons.Filled.Settings, mutableStateOf(false)) {},
SettingItemState("Einstellung 4", "Beschreibung 4", Icons.Filled.Info, mutableStateOf(true)) {},
SettingItemState("Einstellung 5", "Beschreibung 5", Icons.Filled.Settings, mutableStateOf(false)) {}
)
Column {
@ -39,17 +36,17 @@ fun SettingsView(navigator: Navigator) {
}
@Composable
fun Settings(settingsList: List<SettingItem>) {
fun Settings(settingsList: List<SettingItemState>) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(settingsList.size) { index ->
SettingItem(settingsList[index])
Divider(color = Color.LightGray)
//Divider(color = Color.LightGray)
}
}
}
@Composable
fun SettingItem(setting: SettingItem) {
fun SettingItem(setting: SettingItemState) {
Row(
modifier = Modifier
.fillMaxWidth()
@ -61,14 +58,16 @@ fun SettingItem(setting: SettingItem) {
Text(text = setting.title)
Text(text = setting.description, style = MaterialTheme.typography.bodyMedium)
}
Checkbox(checked = setting.value, onCheckedChange = setting.action)
when (setting.value) {
is Boolean -> Checkbox(checked = !(setting.value as Boolean), onCheckedChange = setting.action)
}
}
}
data class SettingItem(
data class SettingItemState(
val title: String,
val description: String,
val icon: ImageVector,
var value: Boolean,
val action: (value: Boolean) -> Unit
var value: Any,
val action: (value: Any) -> Unit
)

View File

@ -1,5 +1,9 @@
package technology.iatlas.spaceup.common
import androidx.compose.runtime.Composable
import androidx.compose.ui.window.Notification
import androidx.compose.ui.window.rememberNotification
import androidx.compose.ui.window.rememberTrayState
import java.awt.Desktop
import java.net.URI
@ -7,6 +11,20 @@ actual fun getPlatformName(): String {
return "Desktop"
}
actual fun openInBrowser(domain: String) {
@Composable
actual fun OpenInBrowser(domain: String) {
Desktop.getDesktop().browse(URI.create(domain))
}
@Composable
actual fun notify(title: String, body: String, type: String) {
val trayState = rememberTrayState()
val notifyType = when(type.uppercase()) {
"INFO" -> Notification.Type.Info
"ERROR" -> Notification.Type.Error
"WARN" -> Notification.Type.Warning
"WARNING" -> Notification.Type.Warning
else -> {Notification.Type.Info}
}
trayState.sendNotification(rememberNotification(title, body, notifyType))
}

View File

@ -9,16 +9,22 @@ import io.ktor.client.plugins.auth.providers.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.*
import io.ktor.serialization.gson.*
import technology.iatlas.spaceup.common.util.Helper.getSystemProfile
val profile = System.getProperty("nextui.profile") ?: ""
val profile = getSystemProfile()
actual fun httpClient(bearerToken: String): HttpClient {
actual fun httpClient(token: String): HttpClient {
return HttpClient(CIO) {
if(profile == "dev") {
if (profile == "dev") {
install(Logging) {
logger = Logger.DEFAULT
level = LogLevel.ALL
}
} else {
install(Logging) {
logger = Logger.DEFAULT
level = LogLevel.INFO
}
}
install(ContentNegotiation) {
gson {
@ -28,12 +34,12 @@ actual fun httpClient(bearerToken: String): HttpClient {
install(Auth) {
bearer {
loadTokens {
BearerTokens(bearerToken, "")
BearerTokens(token, "")
}
}
}
install(HttpTimeout) {
requestTimeoutMillis = 20000 // Webbackend list, Get domains take rather long
requestTimeoutMillis = 60000 // Webbackend list, Get domains take rather long
}
}
}

View File

@ -1,4 +1,3 @@
import org.jetbrains.compose.compose
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
plugins {
@ -11,7 +10,7 @@ version = "1.0-SNAPSHOT"
kotlin {
jvm {
jvmToolchain(11)
jvmToolchain(17)
withJava()
}
sourceSets {
@ -21,7 +20,7 @@ kotlin {
implementation(compose.desktop.currentOs) {
exclude("org.jetbrains.compose.material")
}
implementation("com.bybutter.compose:compose-jetbrains-expui-theme:2.1.0")
implementation("com.bybutter.compose:compose-jetbrains-expui-theme:2.2.0")
}
}
val jvmTest by getting

View File

@ -2,10 +2,8 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Switch
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
@ -33,24 +31,27 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import moe.tlaster.precompose.lifecycle.Lifecycle
import moe.tlaster.precompose.lifecycle.LifecycleOwner
import moe.tlaster.precompose.lifecycle.LifecycleRegistry
import moe.tlaster.precompose.lifecycle.LocalLifecycleOwner
import moe.tlaster.precompose.stateholder.LocalStateHolder
import moe.tlaster.precompose.stateholder.StateHolder
import moe.tlaster.precompose.ui.BackDispatcher
import moe.tlaster.precompose.ui.BackDispatcherOwner
import moe.tlaster.precompose.ui.LocalBackDispatcherOwner
import moe.tlaster.precompose.ui.LocalLifecycleOwner
import moe.tlaster.precompose.ui.LocalViewModelStoreOwner
import moe.tlaster.precompose.viewmodel.ViewModelStore
import moe.tlaster.precompose.viewmodel.ViewModelStoreOwner
import technology.iatlas.spaceup.common.App
import technology.iatlas.spaceup.common.util.Helper.getSystemProfile
import javax.imageio.ImageIO
val profile = System.getProperty("nextui.profile") ?: ""
val profile = getSystemProfile()
@OptIn(ExperimentalFoundationApi::class)
@OptIn(ExperimentalFoundationApi::class,
ExperimentalMaterial3Api::class)
fun main() = application {
var isDark by remember { mutableStateOf(false) }
var isAutoMode by remember { mutableStateOf(false) }
var isAutoMode by remember { mutableStateOf(true) }
val isDarkMode = if(isAutoMode) {
val painterIcon = ImageIO.read(this::class.java.getResourceAsStream("/spaceup_icon.png")).toPainter()
val isDarkMode = if (isAutoMode) {
isSystemInDarkTheme()
} else {
isDark
@ -63,8 +64,8 @@ fun main() = application {
}
val state = rememberWindowState(
size = DpSize(700.dp, 750.dp),
position = WindowPosition(Alignment.BottomCenter)
size = DpSize(600.dp, 650.dp),
position = WindowPosition(Alignment.Center)
)
val holder = remember {
@ -85,17 +86,17 @@ fun main() = application {
ProvideDesktopCompositionLocals(holder) {
JBWindow(
icon = ImageIO.read(this::class.java.getResourceAsStream("/spaceup_icon.png")).toPainter(),
icon = painterIcon,
onCloseRequest = {
holder.lifecycle.currentState = Lifecycle.State.Destroyed
exitApplication()
},
title = "SpaceUp-NextUI ${if(profile.isNotEmpty()) profile.uppercase() else ""}",
title = "SpaceUp-NextUI ${profile.uppercase()}",
theme = currentTheme,
state = state,
mainToolBar = {
Row(Modifier.mainToolBarItem(Alignment.End)) {
if(!isAutoMode) {
if (!isAutoMode) {
Tooltip("Switch between dark and light mode,\ncurrently is ${if (isDark) "dark" else "light"} mode") {
ActionButton(
{ isDark = !isDark }, Modifier.size(40.dp), shape = RectangleShape
@ -133,19 +134,19 @@ private fun ProvideDesktopCompositionLocals(
) {
CompositionLocalProvider(
LocalLifecycleOwner provides holder,
LocalViewModelStoreOwner provides holder,
LocalStateHolder provides holder.stateHolder,
LocalBackDispatcherOwner provides holder,
) {
content.invoke()
}
}
private class PreComposeWindowHolder : LifecycleOwner, ViewModelStoreOwner, BackDispatcherOwner {
private class PreComposeWindowHolder : LifecycleOwner, BackDispatcherOwner {
override val lifecycle by lazy {
LifecycleRegistry()
}
override val viewModelStore by lazy {
ViewModelStore()
val stateHolder by lazy {
StateHolder()
}
override val backDispatcher by lazy {
BackDispatcher()

View File

@ -3,8 +3,9 @@ android.useAndroidX=true
# Kotlin / Compose
kotlin.code.style=official
kotlin.version=1.9.0
compose.version=1.4.3
kotlin.experimental.tryK2=true
kotlin.version=1.9.20
compose.version=1.5.10
# AGP
agp.version=8.0.0

31
qodana.yaml Normal file
View File

@ -0,0 +1,31 @@
#-------------------------------------------------------------------------------#
# Qodana analysis is configured by qodana.yaml file #
# https://www.jetbrains.com/help/qodana/qodana-yaml.html #
#-------------------------------------------------------------------------------#
version: "1.0"
#Specify inspection profile for code analysis
profile:
name: qodana.starter
#Enable inspections
#include:
# - name: <SomeEnabledInspectionId>
#Disable inspections
#exclude:
# - name: <SomeDisabledInspectionId>
# paths:
# - <path/where/not/run/inspection>
projectJDK: jbr-17 #(Applied in CI/CD pipeline)
#Execute shell command before Qodana execution (Applied in CI/CD pipeline)
#bootstrap: sh ./prepare-qodana.sh
#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline)
#plugins:
# - id: <plugin.id> #(plugin id can be found at https://plugins.jetbrains.com)
#Specify Qodana linter for analysis (Applied in CI/CD pipeline)
linter: jetbrains/qodana-jvm:latest