feat: redesign Android app with animations, charts, profile, and registration
- Register screen with validation (username, email, password, confirm) - Profile screen with email editing and password change - User greeting on dashboard showing logged-in username - 401 auto-logout via SessionManager + AuthInterceptor - MetricsChart with Canvas line graphs (CPU, RAM, Disk) - Animated StatCard with counters and icons - ServerCard with progress bars, pulsating status dot, dynamic elevation - Shimmer loading placeholders - Dark mode support with Material3 - Navigation transitions (slide + fade) - New server rack launcher icon - Splash screen - Backend API: register, profile (GET/PUT) endpoints
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -3,3 +3,7 @@
|
|||||||
.idea/
|
.idea/
|
||||||
logs/
|
logs/
|
||||||
*.log
|
*.log
|
||||||
|
android/.gradle/
|
||||||
|
android/build/
|
||||||
|
android/app/build/
|
||||||
|
android/local.properties
|
||||||
|
|||||||
69
android/app/build.gradle.kts
Normal file
69
android/app/build.gradle.kts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.plugin.compose")
|
||||||
|
id("org.jetbrains.kotlin.plugin.serialization")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.devlab.app"
|
||||||
|
compileSdk = 37
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.devlab.app"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 37
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
val composeBom = platform("androidx.compose:compose-bom:2026.05.01")
|
||||||
|
implementation(composeBom)
|
||||||
|
|
||||||
|
implementation("androidx.compose.ui:ui")
|
||||||
|
implementation("androidx.compose.ui:ui-graphics")
|
||||||
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
|
implementation("androidx.compose.material3:material3")
|
||||||
|
implementation("androidx.compose.material:material-icons-extended")
|
||||||
|
|
||||||
|
implementation("androidx.core:core-ktx:1.19.0")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
|
||||||
|
implementation("androidx.activity:activity-compose:1.13.0")
|
||||||
|
|
||||||
|
implementation("androidx.navigation:navigation-compose:2.9.8")
|
||||||
|
|
||||||
|
implementation("com.squareup.retrofit2:retrofit:2.11.0")
|
||||||
|
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
|
||||||
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
|
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
||||||
|
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0")
|
||||||
|
|
||||||
|
implementation("androidx.datastore:datastore-preferences:1.1.3")
|
||||||
|
|
||||||
|
implementation("androidx.core:core-splashscreen:1.0.1")
|
||||||
|
|
||||||
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
|
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||||
|
}
|
||||||
5
android/app/proguard-rules.pro
vendored
Normal file
5
android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Project-wide ProGuard rules
|
||||||
|
-keepattributes Signature
|
||||||
|
-keepattributes *Annotation*
|
||||||
|
-keep class kotlinx.serialization.** { *; }
|
||||||
|
-keepclassmembers class com.devlab.app.data.model.** { *; }
|
||||||
26
android/app/src/main/AndroidManifest.xml
Normal file
26
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".ServerManagerApp"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.ServerManager"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@style/Theme.ServerManager.Splash">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
224
android/app/src/main/java/com/devlab/app/MainActivity.kt
Normal file
224
android/app/src/main/java/com/devlab/app/MainActivity.kt
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
package com.devlab.app
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
|
import androidx.compose.animation.*
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.navigation.NavDestination.Companion.hierarchy
|
||||||
|
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||||
|
import androidx.navigation.NavType
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import androidx.navigation.navArgument
|
||||||
|
import com.devlab.app.data.api.SessionManager
|
||||||
|
import com.devlab.app.navigation.Screen
|
||||||
|
import com.devlab.app.ui.dashboard.DashboardScreen
|
||||||
|
import com.devlab.app.ui.login.LoginScreen
|
||||||
|
import com.devlab.app.ui.login.LoginViewModel
|
||||||
|
import com.devlab.app.ui.profile.ProfileScreen
|
||||||
|
import com.devlab.app.ui.register.RegisterScreen
|
||||||
|
import com.devlab.app.ui.servers.ServerDetailScreen
|
||||||
|
import com.devlab.app.ui.servers.ServerListScreen
|
||||||
|
import com.devlab.app.ui.theme.ServerManagerTheme
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
installSplashScreen()
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
setContent {
|
||||||
|
ServerManagerTheme {
|
||||||
|
AppRoot()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppRoot() {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
val loginViewModel: LoginViewModel = viewModel()
|
||||||
|
var isLoggedIn by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
|
val currentDestination = navBackStackEntry?.destination
|
||||||
|
|
||||||
|
val inLoggedInArea = currentDestination?.route in listOf(
|
||||||
|
Screen.Dashboard.route,
|
||||||
|
Screen.ServerList.route,
|
||||||
|
Screen.Profile.route
|
||||||
|
)
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
SessionManager.onLogoutRequest.collect { requested ->
|
||||||
|
if (requested) {
|
||||||
|
loginViewModel.logout()
|
||||||
|
isLoggedIn = false
|
||||||
|
navController.navigate(Screen.Login.route) {
|
||||||
|
popUpTo(0) { inclusive = true }
|
||||||
|
}
|
||||||
|
SessionManager.consumeLogoutRequest()
|
||||||
|
SessionManager.restoreSession()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
bottomBar = {
|
||||||
|
if (inLoggedInArea) {
|
||||||
|
NavigationBar {
|
||||||
|
NavigationBarItem(
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Dashboard,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = if (currentDestination?.hierarchy?.any { it.route == Screen.Dashboard.route } == true)
|
||||||
|
Modifier.padding(0.dp) else Modifier
|
||||||
|
)
|
||||||
|
},
|
||||||
|
label = { Text("Dashboard") },
|
||||||
|
selected = currentDestination?.hierarchy?.any { it.route == Screen.Dashboard.route } == true,
|
||||||
|
onClick = {
|
||||||
|
navController.navigate(Screen.Dashboard.route) {
|
||||||
|
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
|
||||||
|
launchSingleTop = true
|
||||||
|
restoreState = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
NavigationBarItem(
|
||||||
|
icon = { Icon(Icons.Default.Dns, contentDescription = null) },
|
||||||
|
label = { Text("Servers") },
|
||||||
|
selected = currentDestination?.hierarchy?.any { it.route == Screen.ServerList.route } == true,
|
||||||
|
onClick = {
|
||||||
|
navController.navigate(Screen.ServerList.route) {
|
||||||
|
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
|
||||||
|
launchSingleTop = true
|
||||||
|
restoreState = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
NavigationBarItem(
|
||||||
|
icon = { Icon(Icons.Default.Person, contentDescription = null) },
|
||||||
|
label = { Text("Profile") },
|
||||||
|
selected = currentDestination?.hierarchy?.any { it.route == Screen.Profile.route } == true,
|
||||||
|
onClick = {
|
||||||
|
navController.navigate(Screen.Profile.route) {
|
||||||
|
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
|
||||||
|
launchSingleTop = true
|
||||||
|
restoreState = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
NavigationBarItem(
|
||||||
|
icon = { Icon(Icons.Default.Logout, contentDescription = null) },
|
||||||
|
label = { Text("Logout") },
|
||||||
|
selected = false,
|
||||||
|
onClick = {
|
||||||
|
loginViewModel.logout()
|
||||||
|
isLoggedIn = false
|
||||||
|
navController.navigate(Screen.Login.route) {
|
||||||
|
popUpTo(0) { inclusive = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
NavHost(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = if (isLoggedIn) Screen.Dashboard.route else Screen.Login.route,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
|
enterTransition = { slideInHorizontally(initialOffsetX = { it / 4 }) + fadeIn(animationSpec = tween(300)) },
|
||||||
|
exitTransition = { fadeOut(animationSpec = tween(200)) },
|
||||||
|
popEnterTransition = { fadeIn(animationSpec = tween(200)) },
|
||||||
|
popExitTransition = { slideOutHorizontally(targetOffsetX = { it / 4 }) + fadeOut(animationSpec = tween(200)) }
|
||||||
|
) {
|
||||||
|
composable(Screen.Login.route) {
|
||||||
|
LoginScreen(
|
||||||
|
onConnected = {
|
||||||
|
isLoggedIn = true
|
||||||
|
navController.navigate(Screen.Dashboard.route) {
|
||||||
|
popUpTo(Screen.Login.route) { inclusive = true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRegisterClick = {
|
||||||
|
navController.navigate(Screen.Register.route)
|
||||||
|
},
|
||||||
|
viewModel = loginViewModel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(Screen.Register.route) {
|
||||||
|
RegisterScreen(
|
||||||
|
onRegistered = {
|
||||||
|
isLoggedIn = true
|
||||||
|
navController.navigate(Screen.Dashboard.route) {
|
||||||
|
popUpTo(Screen.Register.route) { inclusive = true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLoginClick = {
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(Screen.Dashboard.route) {
|
||||||
|
DashboardScreen(
|
||||||
|
onLogout = {
|
||||||
|
loginViewModel.logout()
|
||||||
|
isLoggedIn = false
|
||||||
|
navController.navigate(Screen.Login.route) {
|
||||||
|
popUpTo(0) { inclusive = true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onProfileClick = {
|
||||||
|
navController.navigate(Screen.Profile.route)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(Screen.ServerList.route) {
|
||||||
|
ServerListScreen(
|
||||||
|
onServerClick = { serverId ->
|
||||||
|
navController.navigate(Screen.ServerDetail.createRoute(serverId))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(Screen.Profile.route) {
|
||||||
|
ProfileScreen(
|
||||||
|
onBack = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(
|
||||||
|
route = Screen.ServerDetail.route,
|
||||||
|
arguments = listOf(navArgument("serverId") { type = NavType.IntType })
|
||||||
|
) { backStackEntry ->
|
||||||
|
val serverId = backStackEntry.arguments?.getInt("serverId") ?: 0
|
||||||
|
ServerDetailScreen(
|
||||||
|
serverId = serverId,
|
||||||
|
onBack = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
android/app/src/main/java/com/devlab/app/ServerManagerApp.kt
Normal file
15
android/app/src/main/java/com/devlab/app/ServerManagerApp.kt
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package com.devlab.app
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
|
||||||
|
class ServerManagerApp : Application() {
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
instance = this
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
lateinit var instance: ServerManagerApp
|
||||||
|
private set
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.devlab.app.data.api
|
||||||
|
|
||||||
|
import com.devlab.app.data.model.*
|
||||||
|
import retrofit2.http.*
|
||||||
|
|
||||||
|
interface ApiService {
|
||||||
|
|
||||||
|
@POST("api/auth/login")
|
||||||
|
suspend fun login(@Body request: LoginRequest): LoginResponse
|
||||||
|
|
||||||
|
@POST("api/auth/register")
|
||||||
|
suspend fun register(@Body request: RegisterRequest): LoginResponse
|
||||||
|
|
||||||
|
@GET("api/profile")
|
||||||
|
suspend fun getProfile(): ApiResponse<UserProfile>
|
||||||
|
|
||||||
|
@PUT("api/profile")
|
||||||
|
suspend fun updateProfile(@Body request: ProfileUpdateRequest): ApiResponse<Map<String, String>>
|
||||||
|
|
||||||
|
@GET("api/status")
|
||||||
|
suspend fun getStatus(): ApiResponse<DashboardStats>
|
||||||
|
|
||||||
|
@GET("api/servers")
|
||||||
|
suspend fun getServers(
|
||||||
|
@Query("page") page: Int = 1,
|
||||||
|
@Query("per_page") perPage: Int = 20,
|
||||||
|
@Query("search") search: String? = null
|
||||||
|
): ApiResponse<List<Server>>
|
||||||
|
|
||||||
|
@GET("api/servers/{id}")
|
||||||
|
suspend fun getServerDetail(
|
||||||
|
@Path("id") id: Int
|
||||||
|
): ApiResponse<ServerDetail>
|
||||||
|
|
||||||
|
@GET("api/servers/{id}/metrics")
|
||||||
|
suspend fun getServerMetrics(
|
||||||
|
@Path("id") id: Int,
|
||||||
|
@Query("hours") hours: Int = 24
|
||||||
|
): ApiResponse<List<MetricPoint>>
|
||||||
|
|
||||||
|
@POST("api/servers/{id}/execute")
|
||||||
|
suspend fun executeCommand(
|
||||||
|
@Path("id") id: Int,
|
||||||
|
@Body command: CommandRequest
|
||||||
|
): ApiResponse<CommandResult>
|
||||||
|
|
||||||
|
@GET("api/refresh-metrics")
|
||||||
|
suspend fun refreshMetrics(): ApiResponse<Map<String, Any?>>
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.devlab.app.data.api
|
||||||
|
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
|
||||||
|
class AuthInterceptor(private val token: String) : Interceptor {
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val request = chain.request().newBuilder()
|
||||||
|
.addHeader("Authorization", "Bearer $token")
|
||||||
|
.addHeader("Accept", "application/json")
|
||||||
|
.build()
|
||||||
|
val response = chain.proceed(request)
|
||||||
|
|
||||||
|
if (response.code == 401 && token.isNotBlank()) {
|
||||||
|
SessionManager.invalidateSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package com.devlab.app.data.api
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
object RetrofitClient {
|
||||||
|
|
||||||
|
const val BASE_URL = "https://servermanager.devlab.lat/"
|
||||||
|
|
||||||
|
private var apiToken: String = ""
|
||||||
|
private var retrofit: Retrofit? = null
|
||||||
|
private var apiService: ApiService? = null
|
||||||
|
|
||||||
|
private val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
isLenient = true
|
||||||
|
coerceInputValues = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setToken(token: String) {
|
||||||
|
if (token != apiToken) {
|
||||||
|
apiToken = token
|
||||||
|
retrofit = null
|
||||||
|
apiService = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getApiService(): ApiService {
|
||||||
|
if (apiService == null) {
|
||||||
|
val logging = HttpLoggingInterceptor().apply {
|
||||||
|
level = HttpLoggingInterceptor.Level.BASIC
|
||||||
|
}
|
||||||
|
|
||||||
|
val client = OkHttpClient.Builder()
|
||||||
|
.addInterceptor(AuthInterceptor(apiToken))
|
||||||
|
.addInterceptor(logging)
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
retrofit = Retrofit.Builder()
|
||||||
|
.baseUrl(BASE_URL)
|
||||||
|
.client(client)
|
||||||
|
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
apiService = retrofit!!.create(ApiService::class.java)
|
||||||
|
}
|
||||||
|
return apiService!!
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getUnauthenticatedService(): ApiService {
|
||||||
|
val logging = HttpLoggingInterceptor().apply {
|
||||||
|
level = HttpLoggingInterceptor.Level.BASIC
|
||||||
|
}
|
||||||
|
|
||||||
|
val client = OkHttpClient.Builder()
|
||||||
|
.addInterceptor(logging)
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return Retrofit.Builder()
|
||||||
|
.baseUrl(BASE_URL)
|
||||||
|
.client(client)
|
||||||
|
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||||
|
.build()
|
||||||
|
.create(ApiService::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isConfigured(): Boolean = apiToken.isNotBlank()
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.devlab.app.data.api
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
object SessionManager {
|
||||||
|
|
||||||
|
private val _isSessionValid = MutableStateFlow(true)
|
||||||
|
val isSessionValid: StateFlow<Boolean> = _isSessionValid.asStateFlow()
|
||||||
|
|
||||||
|
private val _onLogoutRequest = MutableStateFlow(false)
|
||||||
|
val onLogoutRequest: StateFlow<Boolean> = _onLogoutRequest.asStateFlow()
|
||||||
|
|
||||||
|
fun invalidateSession() {
|
||||||
|
_isSessionValid.value = false
|
||||||
|
_onLogoutRequest.value = true
|
||||||
|
RetrofitClient.setToken("")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun restoreSession() {
|
||||||
|
_isSessionValid.value = true
|
||||||
|
_onLogoutRequest.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun consumeLogoutRequest() {
|
||||||
|
_onLogoutRequest.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.devlab.app.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Pagination(
|
||||||
|
val page: Int = 1,
|
||||||
|
val per_page: Int = 20,
|
||||||
|
val total: Int = 0,
|
||||||
|
val total_pages: Int = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ApiResponse<T>(
|
||||||
|
val success: Boolean,
|
||||||
|
val data: T? = null,
|
||||||
|
val error: String? = null,
|
||||||
|
val pagination: Pagination? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.devlab.app.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CommandRequest(
|
||||||
|
val command: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CommandResult(
|
||||||
|
val output: String? = null,
|
||||||
|
val exit_status: Int? = null,
|
||||||
|
val stderr: String? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.devlab.app.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DashboardStats(
|
||||||
|
val application: String? = null,
|
||||||
|
val version: String? = null,
|
||||||
|
val stats: Stats? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Stats(
|
||||||
|
val total_servers: Int = 0,
|
||||||
|
val online_servers: Int = 0,
|
||||||
|
val offline_servers: Int = 0,
|
||||||
|
val avg_cpu: Double = 0.0,
|
||||||
|
val avg_ram: Double = 0.0,
|
||||||
|
val avg_disk: Double = 0.0
|
||||||
|
)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.devlab.app.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LoginRequest(
|
||||||
|
val username: String,
|
||||||
|
val password: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RegisterRequest(
|
||||||
|
val username: String,
|
||||||
|
val email: String,
|
||||||
|
val password: String,
|
||||||
|
val confirm_password: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ProfileUpdateRequest(
|
||||||
|
val email: String,
|
||||||
|
val current_password: String? = null,
|
||||||
|
val new_password: String? = null,
|
||||||
|
val confirm_password: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LoginResponse(
|
||||||
|
val success: Boolean,
|
||||||
|
val data: LoginData? = null,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LoginData(
|
||||||
|
val token: String = "",
|
||||||
|
val user: LoginUser? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LoginUser(
|
||||||
|
val id: Int = 0,
|
||||||
|
val username: String = "",
|
||||||
|
val email: String = "",
|
||||||
|
val role: String = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UserProfile(
|
||||||
|
val id: Int = 0,
|
||||||
|
val username: String = "",
|
||||||
|
val email: String = "",
|
||||||
|
val role: String = "",
|
||||||
|
val created_at: String? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.devlab.app.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class MetricPoint(
|
||||||
|
val cpu_usage: Double? = null,
|
||||||
|
val ram_usage: Double? = null,
|
||||||
|
val disk_usage: Double? = null,
|
||||||
|
val load_average: String? = null,
|
||||||
|
val recorded_at: String? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.devlab.app.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ProcessInfo(
|
||||||
|
val pid: Int = 0,
|
||||||
|
val name: String = "",
|
||||||
|
val cpu_percent: Double? = null,
|
||||||
|
val memory_percent: Double? = null,
|
||||||
|
val status: String? = null,
|
||||||
|
val user: String? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.devlab.app.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Server(
|
||||||
|
val id: Int = 0,
|
||||||
|
val name: String = "",
|
||||||
|
val ip_address: String = "",
|
||||||
|
val ssh_port: Int = 22,
|
||||||
|
val status: String = "unknown",
|
||||||
|
val group_name: String? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
val cpu_usage: Double? = null,
|
||||||
|
val ram_usage: Double? = null,
|
||||||
|
val disk_usage: Double? = null,
|
||||||
|
val load_average: String? = null,
|
||||||
|
val uptime: String? = null,
|
||||||
|
val created_at: String? = null,
|
||||||
|
val created_by_name: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ServerDetail(
|
||||||
|
val server: Server? = null,
|
||||||
|
val metrics: LatestMetrics? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LatestMetrics(
|
||||||
|
val cpu_usage: Double? = null,
|
||||||
|
val ram_usage: Double? = null,
|
||||||
|
val disk_usage: Double? = null,
|
||||||
|
val load_average: String? = null,
|
||||||
|
val uptime: String? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.devlab.app.data.repository
|
||||||
|
|
||||||
|
import com.devlab.app.data.api.RetrofitClient
|
||||||
|
import com.devlab.app.data.model.LoginRequest
|
||||||
|
import com.devlab.app.data.model.RegisterRequest
|
||||||
|
|
||||||
|
class AuthRepository {
|
||||||
|
|
||||||
|
suspend fun login(username: String, password: String): Result<LoginResult> {
|
||||||
|
return try {
|
||||||
|
val service = RetrofitClient.getUnauthenticatedService()
|
||||||
|
val response = service.login(LoginRequest(username, password))
|
||||||
|
if (response.success && response.data != null) {
|
||||||
|
val token = response.data.token
|
||||||
|
val user = response.data.user
|
||||||
|
if (token.isNotBlank() && user != null) {
|
||||||
|
Result.success(LoginResult(token, user.id, user.username, user.email, user.role))
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception("Empty token received"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception(response.error ?: "Authentication failed"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun register(username: String, email: String, password: String, confirmPassword: String): Result<LoginResult> {
|
||||||
|
return try {
|
||||||
|
val service = RetrofitClient.getUnauthenticatedService()
|
||||||
|
val response = service.register(RegisterRequest(username, email, password, confirmPassword))
|
||||||
|
if (response.success && response.data != null) {
|
||||||
|
val token = response.data.token
|
||||||
|
val user = response.data.user
|
||||||
|
if (token.isNotBlank() && user != null) {
|
||||||
|
Result.success(LoginResult(token, user.id, user.username, user.email, user.role))
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception("Registration succeeded but empty token"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception(response.error ?: "Registration failed"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class LoginResult(
|
||||||
|
val token: String,
|
||||||
|
val userId: Int,
|
||||||
|
val username: String,
|
||||||
|
val email: String,
|
||||||
|
val role: String
|
||||||
|
)
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.devlab.app.data.repository
|
||||||
|
|
||||||
|
import com.devlab.app.data.api.RetrofitClient
|
||||||
|
import com.devlab.app.data.model.ApiResponse
|
||||||
|
import com.devlab.app.data.model.DashboardStats
|
||||||
|
|
||||||
|
class DashboardRepository {
|
||||||
|
|
||||||
|
private val api get() = RetrofitClient.getApiService()
|
||||||
|
|
||||||
|
suspend fun getStatus(): ApiResponse<DashboardStats> {
|
||||||
|
return api.getStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.devlab.app.data.repository
|
||||||
|
|
||||||
|
import com.devlab.app.data.api.RetrofitClient
|
||||||
|
import com.devlab.app.data.model.ProfileUpdateRequest
|
||||||
|
|
||||||
|
class ProfileRepository {
|
||||||
|
|
||||||
|
private val api get() = RetrofitClient.getApiService()
|
||||||
|
|
||||||
|
suspend fun getProfile(): Result<com.devlab.app.data.model.UserProfile> {
|
||||||
|
return try {
|
||||||
|
val response = api.getProfile()
|
||||||
|
if (response.success && response.data != null) {
|
||||||
|
Result.success(response.data)
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception(response.error ?: "Failed to load profile"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateProfile(
|
||||||
|
email: String,
|
||||||
|
currentPassword: String?,
|
||||||
|
newPassword: String?,
|
||||||
|
confirmPassword: String?
|
||||||
|
): Result<String> {
|
||||||
|
return try {
|
||||||
|
val request = ProfileUpdateRequest(
|
||||||
|
email = email,
|
||||||
|
current_password = currentPassword?.takeIf { it.isNotBlank() },
|
||||||
|
new_password = newPassword?.takeIf { it.isNotBlank() },
|
||||||
|
confirm_password = confirmPassword?.takeIf { it.isNotBlank() }
|
||||||
|
)
|
||||||
|
val response = api.updateProfile(request)
|
||||||
|
if (response.success) {
|
||||||
|
Result.success(response.data?.get("message") ?: "Profile updated")
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception(response.error ?: "Update failed"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.devlab.app.data.repository
|
||||||
|
|
||||||
|
import com.devlab.app.data.api.RetrofitClient
|
||||||
|
import com.devlab.app.data.model.*
|
||||||
|
|
||||||
|
class ServerRepository {
|
||||||
|
|
||||||
|
private val api get() = RetrofitClient.getApiService()
|
||||||
|
|
||||||
|
suspend fun getServers(page: Int = 1, perPage: Int = 20, search: String? = null): ApiResponse<List<Server>> {
|
||||||
|
return api.getServers(page, perPage, search)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getServerDetail(id: Int): ApiResponse<ServerDetail> {
|
||||||
|
return api.getServerDetail(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getServerMetrics(id: Int, hours: Int = 24): ApiResponse<List<MetricPoint>> {
|
||||||
|
return api.getServerMetrics(id, hours)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun executeCommand(id: Int, command: String): ApiResponse<CommandResult> {
|
||||||
|
return api.executeCommand(id, CommandRequest(command))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.devlab.app.navigation
|
||||||
|
|
||||||
|
sealed class Screen(val route: String) {
|
||||||
|
data object Login : Screen("login")
|
||||||
|
data object Register : Screen("register")
|
||||||
|
data object Dashboard : Screen("dashboard")
|
||||||
|
data object ServerList : Screen("servers")
|
||||||
|
data object Profile : Screen("profile")
|
||||||
|
data object ServerDetail : Screen("servers/{serverId}") {
|
||||||
|
fun createRoute(serverId: Int) = "servers/$serverId"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package com.devlab.app.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.*
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ShimmerCard(modifier: Modifier = Modifier) {
|
||||||
|
val shimmerColors = listOf(
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.2f),
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
|
||||||
|
val transition = rememberInfiniteTransition(label = "shimmer")
|
||||||
|
val translateAnim by transition.animateFloat(
|
||||||
|
initialValue = 0f,
|
||||||
|
targetValue = 1000f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(durationMillis = 1200, easing = FastOutSlowInEasing),
|
||||||
|
repeatMode = RepeatMode.Restart
|
||||||
|
),
|
||||||
|
label = "shimmer"
|
||||||
|
)
|
||||||
|
|
||||||
|
val brush = Brush.linearGradient(
|
||||||
|
colors = shimmerColors,
|
||||||
|
start = Offset.Zero,
|
||||||
|
end = Offset(x = translateAnim, y = translateAnim)
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 6.dp)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(120.dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(brush)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoadingIndicator(modifier: Modifier = Modifier) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
repeat(4) {
|
||||||
|
ShimmerCard()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package com.devlab.app.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.*
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.graphics.StrokeJoin
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.graphics.nativeCanvas
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.devlab.app.data.model.MetricPoint
|
||||||
|
import com.devlab.app.ui.theme.*
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MetricsChart(
|
||||||
|
points: List<MetricPoint>,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
if (points.isEmpty()) {
|
||||||
|
Box(modifier = modifier.fillMaxWidth().height(200.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "No metrics data available",
|
||||||
|
color = Grey500,
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val animationProgress by animateFloatAsState(
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = tween(1500, easing = FastOutSlowInEasing),
|
||||||
|
label = "chartAnim"
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
LegendItem(color = Orange500, label = "CPU")
|
||||||
|
LegendItem(color = Blue700, label = "RAM")
|
||||||
|
LegendItem(color = Green500, label = "Disk")
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Canvas(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(200.dp)
|
||||||
|
) {
|
||||||
|
val chartWidth = size.width
|
||||||
|
val chartHeight = size.height
|
||||||
|
val padding = 40f
|
||||||
|
val drawWidth = chartWidth - padding * 2
|
||||||
|
val drawHeight = chartHeight - padding * 2
|
||||||
|
|
||||||
|
val cpuValues = points.map { it.cpu_usage ?: 0.0 }
|
||||||
|
val ramValues = points.map { it.ram_usage ?: 0.0 }
|
||||||
|
val diskValues = points.map { it.disk_usage ?: 0.0 }
|
||||||
|
val maxVal = 100.0
|
||||||
|
|
||||||
|
fun toX(index: Int): Float {
|
||||||
|
if (points.size <= 1) return padding
|
||||||
|
return padding + (index.toFloat() / (points.size - 1).coerceAtLeast(1)) * drawWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toY(value: Double): Float {
|
||||||
|
return (padding + drawHeight - ((value / maxVal) * drawHeight)).toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
drawLine(
|
||||||
|
color = Color.LightGray.copy(alpha = 0.5f),
|
||||||
|
start = Offset(padding, padding),
|
||||||
|
end = Offset(padding, padding + drawHeight),
|
||||||
|
strokeWidth = 1f
|
||||||
|
)
|
||||||
|
drawLine(
|
||||||
|
color = Color.LightGray.copy(alpha = 0.5f),
|
||||||
|
start = Offset(padding, padding + drawHeight),
|
||||||
|
end = Offset(padding + drawWidth, padding + drawHeight),
|
||||||
|
strokeWidth = 1f
|
||||||
|
)
|
||||||
|
|
||||||
|
val gridSteps = 4
|
||||||
|
for (i in 0..gridSteps) {
|
||||||
|
val y = padding + (drawHeight / gridSteps) * i
|
||||||
|
drawLine(
|
||||||
|
color = Color.LightGray.copy(alpha = 0.3f),
|
||||||
|
start = Offset(padding, y),
|
||||||
|
end = Offset(padding + drawWidth, y),
|
||||||
|
strokeWidth = 1f
|
||||||
|
)
|
||||||
|
val label = "${(maxVal - (maxVal / gridSteps) * i).toInt()}%"
|
||||||
|
drawContext.canvas.nativeCanvas.drawText(
|
||||||
|
label,
|
||||||
|
4f,
|
||||||
|
y + 4f,
|
||||||
|
android.graphics.Paint().apply {
|
||||||
|
color = android.graphics.Color.GRAY
|
||||||
|
textSize = 22f
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun drawMetricLine(values: List<Double>, color: Color) {
|
||||||
|
if (points.size < 2) return
|
||||||
|
val path = Path()
|
||||||
|
val count = (points.size * animationProgress).toInt().coerceIn(2, points.size)
|
||||||
|
|
||||||
|
for (i in 0 until count) {
|
||||||
|
val x = toX(i)
|
||||||
|
val y = toY(values[i])
|
||||||
|
if (i == 0) path.moveTo(x, y)
|
||||||
|
else path.lineTo(x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
drawPath(
|
||||||
|
path = path,
|
||||||
|
color = color,
|
||||||
|
style = Stroke(
|
||||||
|
width = 3f,
|
||||||
|
cap = StrokeCap.Round,
|
||||||
|
join = StrokeJoin.Round
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
drawCircle(color, 5f, Offset(toX(count - 1), toY(values[count - 1])))
|
||||||
|
}
|
||||||
|
|
||||||
|
drawMetricLine(cpuValues, Orange500)
|
||||||
|
drawMetricLine(ramValues, Blue700)
|
||||||
|
drawMetricLine(diskValues, Green500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LegendItem(color: Color, label: String) {
|
||||||
|
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||||
|
Canvas(modifier = Modifier.size(10.dp)) {
|
||||||
|
drawCircle(color = color)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(text = label, style = MaterialTheme.typography.labelSmall, color = Grey500)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package com.devlab.app.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.*
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.devlab.app.data.model.Server
|
||||||
|
import com.devlab.app.ui.theme.*
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ServerCard(
|
||||||
|
server: Server,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val statusColor = when (server.status) {
|
||||||
|
"online" -> Green500
|
||||||
|
"offline" -> Red500
|
||||||
|
"disabled" -> Grey500
|
||||||
|
else -> Orange500
|
||||||
|
}
|
||||||
|
val statusText = server.status.replaceFirstChar { it.uppercase() }
|
||||||
|
|
||||||
|
val infiniteTransition = rememberInfiniteTransition(label = "status")
|
||||||
|
val pulseAlpha by infiniteTransition.animateFloat(
|
||||||
|
initialValue = 0.3f,
|
||||||
|
targetValue = 0.8f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(1000, easing = FastOutSlowInEasing),
|
||||||
|
repeatMode = RepeatMode.Reverse
|
||||||
|
),
|
||||||
|
label = "pulse"
|
||||||
|
)
|
||||||
|
|
||||||
|
var pressed by remember { mutableStateOf(false) }
|
||||||
|
val elevation by animateDpAsState(
|
||||||
|
targetValue = if (pressed) 8.dp else 2.dp,
|
||||||
|
animationSpec = spring(dampingRatio = 0.5f, stiffness = 500f),
|
||||||
|
label = "elevation"
|
||||||
|
)
|
||||||
|
|
||||||
|
Card(
|
||||||
|
onClick = {
|
||||||
|
pressed = true
|
||||||
|
onClick()
|
||||||
|
},
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = elevation),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(10.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(
|
||||||
|
if (server.status == "online") statusColor.copy(alpha = pulseAlpha)
|
||||||
|
else statusColor
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(10.dp))
|
||||||
|
Text(
|
||||||
|
text = server.name,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
color = statusColor.copy(alpha = 0.15f),
|
||||||
|
shape = RoundedCornerShape(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = statusText,
|
||||||
|
color = statusColor,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "${server.ip_address}:${server.ssh_port}",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = Grey700
|
||||||
|
)
|
||||||
|
|
||||||
|
if (server.group_name != null) {
|
||||||
|
Text(
|
||||||
|
text = server.group_name,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = Grey500
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
MetricProgress("CPU", server.cpu_usage, Orange500)
|
||||||
|
MetricProgress("RAM", server.ram_usage, Blue700)
|
||||||
|
MetricProgress("Disk", server.disk_usage, Green500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MetricProgress(label: String, value: Double?, color: Color) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.width(72.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (value != null) "${value.toInt()}%" else "N/A",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { ((value ?: 0.0) / 100.0).toFloat() },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(4.dp)
|
||||||
|
.clip(RoundedCornerShape(2.dp)),
|
||||||
|
color = color,
|
||||||
|
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = Grey500
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package com.devlab.app.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateIntAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
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.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun StatCard(
|
||||||
|
title: String,
|
||||||
|
value: String,
|
||||||
|
color: Color,
|
||||||
|
icon: ImageVector? = null,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val numStr = value.filter { it.isDigit() }
|
||||||
|
val numericValue = numStr.toIntOrNull() ?: 0
|
||||||
|
val animatedCount by animateIntAsState(
|
||||||
|
targetValue = numericValue,
|
||||||
|
animationSpec = tween(800, delayMillis = 100),
|
||||||
|
label = "statCount"
|
||||||
|
)
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = modifier,
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
if (icon != null) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = color,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = if (numStr.isNotEmpty()) "${animatedCount}${if (value.endsWith("%")) "%" else ""}" else value,
|
||||||
|
fontSize = 28.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = color
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package com.devlab.app.ui.dashboard
|
||||||
|
|
||||||
|
import androidx.compose.animation.*
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.devlab.app.ui.components.LoadingIndicator
|
||||||
|
import com.devlab.app.ui.components.StatCard
|
||||||
|
import com.devlab.app.ui.theme.*
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun DashboardScreen(
|
||||||
|
onLogout: () -> Unit,
|
||||||
|
onProfileClick: () -> Unit = {},
|
||||||
|
viewModel: DashboardViewModel = viewModel()
|
||||||
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Column {
|
||||||
|
Text("Dashboard")
|
||||||
|
if (state.username.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = "Welcome, ${state.username}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onPrimary
|
||||||
|
),
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { onProfileClick() }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.AccountCircle,
|
||||||
|
contentDescription = "Profile",
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = { viewModel.loadStats() }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Refresh,
|
||||||
|
contentDescription = "Refresh",
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
when {
|
||||||
|
state.isLoading -> {
|
||||||
|
LoadingIndicator(modifier = Modifier.padding(padding))
|
||||||
|
}
|
||||||
|
state.error != null -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.ErrorOutline,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text = state.error ?: "",
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodyLarge
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
OutlinedButton(onClick = { viewModel.loadStats() }) {
|
||||||
|
Text("Retry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val dashboardStats = state.stats
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
if (dashboardStats?.application != null) {
|
||||||
|
Text(
|
||||||
|
text = "${dashboardStats.application} v${dashboardStats.version ?: ""}",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Servers",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
StatCard(
|
||||||
|
title = "Total",
|
||||||
|
value = "${dashboardStats?.stats?.total_servers ?: 0}",
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
icon = Icons.Default.Dns,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
StatCard(
|
||||||
|
title = "Online",
|
||||||
|
value = "${dashboardStats?.stats?.online_servers ?: 0}",
|
||||||
|
color = Green500,
|
||||||
|
icon = Icons.Default.CheckCircle,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
StatCard(
|
||||||
|
title = "Offline",
|
||||||
|
value = "${dashboardStats?.stats?.offline_servers ?: 0}",
|
||||||
|
color = Red500,
|
||||||
|
icon = Icons.Default.Cancel,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Average Usage",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
StatCard(
|
||||||
|
title = "CPU",
|
||||||
|
value = "${dashboardStats?.stats?.avg_cpu?.toInt() ?: 0}%",
|
||||||
|
color = Orange500,
|
||||||
|
icon = Icons.Default.Memory,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
StatCard(
|
||||||
|
title = "RAM",
|
||||||
|
value = "${dashboardStats?.stats?.avg_ram?.toInt() ?: 0}%",
|
||||||
|
color = Blue700,
|
||||||
|
icon = Icons.Default.Storage,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
StatCard(
|
||||||
|
title = "Disk",
|
||||||
|
value = "${dashboardStats?.stats?.avg_disk?.toInt() ?: 0}%",
|
||||||
|
color = Green500,
|
||||||
|
icon = Icons.Default.SdStorage,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package com.devlab.app.ui.dashboard
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.devlab.app.ServerManagerApp
|
||||||
|
import com.devlab.app.data.model.DashboardStats
|
||||||
|
import com.devlab.app.data.repository.DashboardRepository
|
||||||
|
import com.devlab.app.util.PreferencesManager
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class DashboardUiState(
|
||||||
|
val isLoading: Boolean = true,
|
||||||
|
val stats: DashboardStats? = null,
|
||||||
|
val error: String? = null,
|
||||||
|
val username: String = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
class DashboardViewModel : ViewModel() {
|
||||||
|
|
||||||
|
private val repository = DashboardRepository()
|
||||||
|
private val prefs = PreferencesManager(ServerManagerApp.instance)
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(DashboardUiState())
|
||||||
|
val uiState: StateFlow<DashboardUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val savedUsername = prefs.getUsername()
|
||||||
|
_uiState.value = _uiState.value.copy(username = savedUsername)
|
||||||
|
}
|
||||||
|
loadStats()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadStats() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
||||||
|
try {
|
||||||
|
val response = repository.getStatus()
|
||||||
|
if (response.success) {
|
||||||
|
_uiState.value = DashboardUiState(
|
||||||
|
isLoading = false,
|
||||||
|
stats = response.data,
|
||||||
|
username = _uiState.value.username
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
_uiState.value = DashboardUiState(
|
||||||
|
isLoading = false,
|
||||||
|
error = response.error ?: "Failed to load stats",
|
||||||
|
username = _uiState.value.username
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = DashboardUiState(
|
||||||
|
isLoading = false,
|
||||||
|
error = e.message ?: "Network error",
|
||||||
|
username = _uiState.value.username
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
224
android/app/src/main/java/com/devlab/app/ui/login/LoginScreen.kt
Normal file
224
android/app/src/main/java/com/devlab/app/ui/login/LoginScreen.kt
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
package com.devlab.app.ui.login
|
||||||
|
|
||||||
|
import androidx.compose.animation.*
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoginScreen(
|
||||||
|
onConnected: () -> Unit,
|
||||||
|
onRegisterClick: () -> Unit = {},
|
||||||
|
viewModel: LoginViewModel = viewModel()
|
||||||
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
var passwordVisible by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(state.isConnected) {
|
||||||
|
if (state.isConnected) onConnected()
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
colors = listOf(
|
||||||
|
MaterialTheme.colorScheme.primary.copy(alpha = 0.08f),
|
||||||
|
MaterialTheme.colorScheme.surface
|
||||||
|
),
|
||||||
|
startY = 0f,
|
||||||
|
endY = 600f
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(48.dp))
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.size(88.dp),
|
||||||
|
shape = MaterialTheme.shapes.extraLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primaryContainer
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Dns,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.fillMaxSize(),
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "ServerManager",
|
||||||
|
style = MaterialTheme.typography.headlineLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Sign in to manage your servers",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(40.dp))
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(24.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Welcome back",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.username,
|
||||||
|
onValueChange = viewModel::updateUsername,
|
||||||
|
label = { Text("Username") },
|
||||||
|
placeholder = { Text("Enter your username") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(Icons.Default.Person, contentDescription = null)
|
||||||
|
},
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Text,
|
||||||
|
imeAction = ImeAction.Next
|
||||||
|
),
|
||||||
|
enabled = !state.isLoading
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.password,
|
||||||
|
onValueChange = viewModel::updatePassword,
|
||||||
|
label = { Text("Password") },
|
||||||
|
placeholder = { Text("Enter your password") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(Icons.Default.Lock, contentDescription = null)
|
||||||
|
},
|
||||||
|
visualTransformation = if (passwordVisible) VisualTransformation.None
|
||||||
|
else PasswordVisualTransformation(),
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (passwordVisible) Icons.Default.Visibility
|
||||||
|
else Icons.Default.VisibilityOff,
|
||||||
|
contentDescription = if (passwordVisible) "Hide password" else "Show password"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
imeAction = ImeAction.Done
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(onDone = { viewModel.login() }),
|
||||||
|
enabled = !state.isLoading
|
||||||
|
)
|
||||||
|
|
||||||
|
if (state.error != null) {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = state.error ?: "",
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.padding(12.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.login() },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(50.dp),
|
||||||
|
enabled = !state.isLoading
|
||||||
|
) {
|
||||||
|
if (state.isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Login,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Sign In", fontWeight = FontWeight.SemiBold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
TextButton(onClick = onRegisterClick) {
|
||||||
|
Text(
|
||||||
|
text = "Don't have an account? Register",
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(48.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package com.devlab.app.ui.login
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.devlab.app.ServerManagerApp
|
||||||
|
import com.devlab.app.data.api.RetrofitClient
|
||||||
|
import com.devlab.app.data.repository.AuthRepository
|
||||||
|
import com.devlab.app.util.PreferencesManager
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class LoginUiState(
|
||||||
|
val username: String = "",
|
||||||
|
val password: String = "",
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
val isConnected: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
class LoginViewModel : ViewModel() {
|
||||||
|
|
||||||
|
private val repository = AuthRepository()
|
||||||
|
private val prefs = PreferencesManager(ServerManagerApp.instance)
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(LoginUiState())
|
||||||
|
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val savedToken = prefs.getToken()
|
||||||
|
if (savedToken.isNotBlank()) {
|
||||||
|
val savedUsername = prefs.getUsername()
|
||||||
|
RetrofitClient.setToken(savedToken)
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isConnected = true,
|
||||||
|
username = savedUsername
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateUsername(value: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(username = value, error = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updatePassword(value: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(password = value, error = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun login() {
|
||||||
|
val state = _uiState.value
|
||||||
|
if (state.username.isBlank()) {
|
||||||
|
_uiState.value = state.copy(error = "Please enter your username")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (state.password.isBlank()) {
|
||||||
|
_uiState.value = state.copy(error = "Please enter your password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
||||||
|
val result = repository.login(state.username, state.password)
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { loginResult ->
|
||||||
|
RetrofitClient.setToken(loginResult.token)
|
||||||
|
prefs.saveUserData(
|
||||||
|
loginResult.token, loginResult.userId,
|
||||||
|
loginResult.username, loginResult.email, loginResult.role
|
||||||
|
)
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
isConnected = true,
|
||||||
|
error = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = e.message ?: "Login failed"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logout() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
prefs.clear()
|
||||||
|
RetrofitClient.setToken("")
|
||||||
|
_uiState.value = LoginUiState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
package com.devlab.app.ui.profile
|
||||||
|
|
||||||
|
import androidx.compose.animation.*
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ProfileScreen(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
viewModel: ProfileViewModel = viewModel()
|
||||||
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
var currentPasswordVisible by remember { mutableStateOf(false) }
|
||||||
|
var newPasswordVisible by remember { mutableStateOf(false) }
|
||||||
|
var confirmPasswordVisible by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) { viewModel.loadProfile() }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Profile") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.AccountCircle,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(80.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = state.username,
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
AssistChip(
|
||||||
|
onClick = {},
|
||||||
|
label = { Text(state.role.replaceFirstChar { it.uppercase() }) },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Shield,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
|
),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "Account Information",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.username,
|
||||||
|
onValueChange = {},
|
||||||
|
label = { Text("Username") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
leadingIcon = { Icon(Icons.Default.Person, contentDescription = null) },
|
||||||
|
enabled = false,
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.email,
|
||||||
|
onValueChange = viewModel::updateEmail,
|
||||||
|
label = { Text("Email") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) },
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Email,
|
||||||
|
imeAction = ImeAction.Next
|
||||||
|
),
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !state.isSaving,
|
||||||
|
isError = state.emailError != null
|
||||||
|
)
|
||||||
|
if (state.emailError != null) {
|
||||||
|
Text(
|
||||||
|
text = state.emailError ?: "",
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, top = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
|
),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "Change Password",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.currentPassword,
|
||||||
|
onValueChange = viewModel::updateCurrentPassword,
|
||||||
|
label = { Text("Current Password") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) },
|
||||||
|
visualTransformation = if (currentPasswordVisible) VisualTransformation.None
|
||||||
|
else PasswordVisualTransformation(),
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { currentPasswordVisible = !currentPasswordVisible }) {
|
||||||
|
Icon(
|
||||||
|
if (currentPasswordVisible) Icons.Default.Visibility
|
||||||
|
else Icons.Default.VisibilityOff,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !state.isSaving
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.newPassword,
|
||||||
|
onValueChange = viewModel::updateNewPassword,
|
||||||
|
label = { Text("New Password") },
|
||||||
|
placeholder = { Text("Leave blank to keep current") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) },
|
||||||
|
visualTransformation = if (newPasswordVisible) VisualTransformation.None
|
||||||
|
else PasswordVisualTransformation(),
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { newPasswordVisible = !newPasswordVisible }) {
|
||||||
|
Icon(
|
||||||
|
if (newPasswordVisible) Icons.Default.Visibility
|
||||||
|
else Icons.Default.VisibilityOff,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !state.isSaving,
|
||||||
|
isError = state.newPasswordError != null
|
||||||
|
)
|
||||||
|
if (state.newPasswordError != null) {
|
||||||
|
Text(
|
||||||
|
text = state.newPasswordError ?: "",
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, top = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.confirmPassword,
|
||||||
|
onValueChange = viewModel::updateConfirmPassword,
|
||||||
|
label = { Text("Confirm New Password") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) },
|
||||||
|
visualTransformation = if (confirmPasswordVisible) VisualTransformation.None
|
||||||
|
else PasswordVisualTransformation(),
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { confirmPasswordVisible = !confirmPasswordVisible }) {
|
||||||
|
Icon(
|
||||||
|
if (confirmPasswordVisible) Icons.Default.Visibility
|
||||||
|
else Icons.Default.VisibilityOff,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !state.isSaving,
|
||||||
|
isError = state.confirmPasswordError != null
|
||||||
|
)
|
||||||
|
if (state.confirmPasswordError != null) {
|
||||||
|
Text(
|
||||||
|
text = state.confirmPasswordError ?: "",
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, top = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.error != null) {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = state.error ?: "",
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.padding(12.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.successMessage != null) {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = state.successMessage ?: "",
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.padding(12.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.save() },
|
||||||
|
modifier = Modifier.fillMaxWidth().height(50.dp),
|
||||||
|
enabled = !state.isSaving
|
||||||
|
) {
|
||||||
|
if (state.isSaving) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(Icons.Default.Save, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Save Changes", fontWeight = FontWeight.SemiBold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package com.devlab.app.ui.profile
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.devlab.app.ServerManagerApp
|
||||||
|
import com.devlab.app.data.repository.ProfileRepository
|
||||||
|
import com.devlab.app.util.PreferencesManager
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class ProfileUiState(
|
||||||
|
val isLoading: Boolean = true,
|
||||||
|
val username: String = "",
|
||||||
|
val email: String = "",
|
||||||
|
val role: String = "",
|
||||||
|
val currentPassword: String = "",
|
||||||
|
val newPassword: String = "",
|
||||||
|
val confirmPassword: String = "",
|
||||||
|
val isSaving: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
val successMessage: String? = null,
|
||||||
|
val emailError: String? = null,
|
||||||
|
val newPasswordError: String? = null,
|
||||||
|
val confirmPasswordError: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
class ProfileViewModel : ViewModel() {
|
||||||
|
|
||||||
|
private val repository = ProfileRepository()
|
||||||
|
private val prefs = PreferencesManager(ServerManagerApp.instance)
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(ProfileUiState())
|
||||||
|
val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
fun loadProfile() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||||
|
val result = repository.getProfile()
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { profile ->
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
username = profile.username,
|
||||||
|
email = profile.email,
|
||||||
|
role = profile.role
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = e.message ?: "Failed to load profile"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateEmail(value: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(email = value, error = null, successMessage = null, emailError = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateCurrentPassword(value: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(currentPassword = value, error = null, successMessage = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateNewPassword(value: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(newPassword = value, error = null, successMessage = null, newPasswordError = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateConfirmPassword(value: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(confirmPassword = value, error = null, successMessage = null, confirmPasswordError = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save() {
|
||||||
|
val state = _uiState.value
|
||||||
|
var hasError = false
|
||||||
|
|
||||||
|
if (!android.util.Patterns.EMAIL_ADDRESS.matcher(state.email).matches()) {
|
||||||
|
_uiState.value = _uiState.value.copy(emailError = "Enter a valid email address")
|
||||||
|
hasError = true
|
||||||
|
}
|
||||||
|
if (state.newPassword.isNotBlank()) {
|
||||||
|
if (state.newPassword.length < 8) {
|
||||||
|
_uiState.value = _uiState.value.copy(newPasswordError = "Min 8 characters")
|
||||||
|
hasError = true
|
||||||
|
}
|
||||||
|
if (state.confirmPassword != state.newPassword) {
|
||||||
|
_uiState.value = _uiState.value.copy(confirmPasswordError = "Passwords do not match")
|
||||||
|
hasError = true
|
||||||
|
}
|
||||||
|
if (state.currentPassword.isBlank()) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = "Enter current password to change password")
|
||||||
|
hasError = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasError) return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = _uiState.value.copy(isSaving = true, error = null, successMessage = null)
|
||||||
|
val result = repository.updateProfile(
|
||||||
|
state.email,
|
||||||
|
state.currentPassword.takeIf { it.isNotBlank() },
|
||||||
|
state.newPassword.takeIf { it.isNotBlank() },
|
||||||
|
state.confirmPassword.takeIf { it.isNotBlank() }
|
||||||
|
)
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { message ->
|
||||||
|
prefs.saveProfile(state.email)
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isSaving = false,
|
||||||
|
currentPassword = "",
|
||||||
|
newPassword = "",
|
||||||
|
confirmPassword = "",
|
||||||
|
successMessage = message
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isSaving = false,
|
||||||
|
error = e.message ?: "Update failed"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
package com.devlab.app.ui.register
|
||||||
|
|
||||||
|
import androidx.compose.animation.*
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RegisterScreen(
|
||||||
|
onRegistered: () -> Unit,
|
||||||
|
onLoginClick: () -> Unit,
|
||||||
|
viewModel: RegisterViewModel = viewModel()
|
||||||
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
var passwordVisible by remember { mutableStateOf(false) }
|
||||||
|
var confirmPasswordVisible by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(state.isRegistered) {
|
||||||
|
if (state.isRegistered) onRegistered()
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.PersonAdd,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(64.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Create Account",
|
||||||
|
style = MaterialTheme.typography.headlineLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Register to manage your servers",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.username,
|
||||||
|
onValueChange = viewModel::updateUsername,
|
||||||
|
label = { Text("Username") },
|
||||||
|
placeholder = { Text("Choose a username") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
leadingIcon = { Icon(Icons.Default.Person, contentDescription = null) },
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||||
|
enabled = !state.isLoading,
|
||||||
|
isError = state.usernameError != null
|
||||||
|
)
|
||||||
|
if (state.usernameError != null) {
|
||||||
|
Text(
|
||||||
|
text = state.usernameError ?: "",
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.email,
|
||||||
|
onValueChange = viewModel::updateEmail,
|
||||||
|
label = { Text("Email") },
|
||||||
|
placeholder = { Text("Enter your email") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next),
|
||||||
|
enabled = !state.isLoading,
|
||||||
|
isError = state.emailError != null
|
||||||
|
)
|
||||||
|
if (state.emailError != null) {
|
||||||
|
Text(
|
||||||
|
text = state.emailError ?: "",
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.password,
|
||||||
|
onValueChange = viewModel::updatePassword,
|
||||||
|
label = { Text("Password") },
|
||||||
|
placeholder = { Text("Min. 8 characters") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) },
|
||||||
|
visualTransformation = if (passwordVisible) VisualTransformation.None
|
||||||
|
else PasswordVisualTransformation(),
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (passwordVisible) Icons.Default.Visibility
|
||||||
|
else Icons.Default.VisibilityOff,
|
||||||
|
contentDescription = if (passwordVisible) "Hide password" else "Show password"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next),
|
||||||
|
enabled = !state.isLoading,
|
||||||
|
isError = state.passwordError != null
|
||||||
|
)
|
||||||
|
if (state.passwordError != null) {
|
||||||
|
Text(
|
||||||
|
text = state.passwordError ?: "",
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.confirmPassword,
|
||||||
|
onValueChange = viewModel::updateConfirmPassword,
|
||||||
|
label = { Text("Confirm Password") },
|
||||||
|
placeholder = { Text("Repeat your password") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) },
|
||||||
|
visualTransformation = if (confirmPasswordVisible) VisualTransformation.None
|
||||||
|
else PasswordVisualTransformation(),
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { confirmPasswordVisible = !confirmPasswordVisible }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (confirmPasswordVisible) Icons.Default.Visibility
|
||||||
|
else Icons.Default.VisibilityOff,
|
||||||
|
contentDescription = if (confirmPasswordVisible) "Hide password" else "Show password"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
|
||||||
|
keyboardActions = KeyboardActions(onDone = { viewModel.register() }),
|
||||||
|
enabled = !state.isLoading,
|
||||||
|
isError = state.confirmPasswordError != null
|
||||||
|
)
|
||||||
|
if (state.confirmPasswordError != null) {
|
||||||
|
Text(
|
||||||
|
text = state.confirmPasswordError ?: "",
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.error != null) {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = state.error ?: "",
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.padding(12.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.register() },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(50.dp),
|
||||||
|
enabled = !state.isLoading
|
||||||
|
) {
|
||||||
|
if (state.isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Create Account", fontWeight = FontWeight.SemiBold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
TextButton(onClick = onLoginClick) {
|
||||||
|
Text("Already have an account? Sign In")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package com.devlab.app.ui.register
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.devlab.app.ServerManagerApp
|
||||||
|
import com.devlab.app.data.api.RetrofitClient
|
||||||
|
import com.devlab.app.data.repository.AuthRepository
|
||||||
|
import com.devlab.app.util.PreferencesManager
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class RegisterUiState(
|
||||||
|
val username: String = "",
|
||||||
|
val email: String = "",
|
||||||
|
val password: String = "",
|
||||||
|
val confirmPassword: String = "",
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
val isRegistered: Boolean = false,
|
||||||
|
val usernameError: String? = null,
|
||||||
|
val emailError: String? = null,
|
||||||
|
val passwordError: String? = null,
|
||||||
|
val confirmPasswordError: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
class RegisterViewModel : ViewModel() {
|
||||||
|
|
||||||
|
private val repository = AuthRepository()
|
||||||
|
private val prefs = PreferencesManager(ServerManagerApp.instance)
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(RegisterUiState())
|
||||||
|
val uiState: StateFlow<RegisterUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
fun updateUsername(value: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(username = value, error = null, usernameError = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateEmail(value: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(email = value, error = null, emailError = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updatePassword(value: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(password = value, error = null, passwordError = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateConfirmPassword(value: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(confirmPassword = value, error = null, confirmPasswordError = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun register() {
|
||||||
|
val state = _uiState.value
|
||||||
|
var hasError = false
|
||||||
|
|
||||||
|
if (state.username.length < 3) {
|
||||||
|
_uiState.value = _uiState.value.copy(usernameError = "Username must be at least 3 characters")
|
||||||
|
hasError = true
|
||||||
|
}
|
||||||
|
if (!android.util.Patterns.EMAIL_ADDRESS.matcher(state.email).matches()) {
|
||||||
|
_uiState.value = _uiState.value.copy(emailError = "Enter a valid email address")
|
||||||
|
hasError = true
|
||||||
|
}
|
||||||
|
if (state.password.length < 8) {
|
||||||
|
_uiState.value = _uiState.value.copy(passwordError = "Password must be at least 8 characters")
|
||||||
|
hasError = true
|
||||||
|
}
|
||||||
|
if (state.confirmPassword != state.password) {
|
||||||
|
_uiState.value = _uiState.value.copy(confirmPasswordError = "Passwords do not match")
|
||||||
|
hasError = true
|
||||||
|
}
|
||||||
|
if (hasError) return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
||||||
|
val result = repository.register(
|
||||||
|
state.username, state.email,
|
||||||
|
state.password, state.confirmPassword
|
||||||
|
)
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { loginResult ->
|
||||||
|
RetrofitClient.setToken(loginResult.token)
|
||||||
|
prefs.saveUserData(
|
||||||
|
loginResult.token, loginResult.userId,
|
||||||
|
loginResult.username, loginResult.email, loginResult.role
|
||||||
|
)
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
isRegistered = true
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = e.message ?: "Registration failed"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,513 @@
|
|||||||
|
package com.devlab.app.ui.servers
|
||||||
|
|
||||||
|
import androidx.compose.animation.*
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.devlab.app.ui.components.LoadingIndicator
|
||||||
|
import com.devlab.app.ui.components.MetricsChart
|
||||||
|
import com.devlab.app.ui.components.StatCard
|
||||||
|
import com.devlab.app.ui.theme.*
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ServerDetailScreen(
|
||||||
|
serverId: Int,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
viewModel: ServerDetailViewModel = viewModel()
|
||||||
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
LaunchedEffect(serverId) {
|
||||||
|
viewModel.load(serverId)
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(state.server?.name ?: "Server Detail") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
when {
|
||||||
|
state.isLoading -> LoadingIndicator(modifier = Modifier.padding(padding))
|
||||||
|
state.error != null -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.ErrorOutline,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text = state.error ?: "",
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
OutlinedButton(onClick = { viewModel.load(serverId) }) {
|
||||||
|
Text("Retry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
) {
|
||||||
|
TabRow(
|
||||||
|
selectedTabIndex = state.selectedTab,
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
contentColor = MaterialTheme.colorScheme.primary
|
||||||
|
) {
|
||||||
|
Tab(
|
||||||
|
selected = state.selectedTab == 0,
|
||||||
|
onClick = { viewModel.selectTab(0) },
|
||||||
|
text = { Text("Info") },
|
||||||
|
icon = { Icon(Icons.Default.Info, contentDescription = null, modifier = Modifier.size(18.dp)) }
|
||||||
|
)
|
||||||
|
Tab(
|
||||||
|
selected = state.selectedTab == 1,
|
||||||
|
onClick = { viewModel.selectTab(1) },
|
||||||
|
text = { Text("Metrics") },
|
||||||
|
icon = { Icon(Icons.Default.StackedLineChart, contentDescription = null, modifier = Modifier.size(18.dp)) }
|
||||||
|
)
|
||||||
|
Tab(
|
||||||
|
selected = state.selectedTab == 2,
|
||||||
|
onClick = { viewModel.selectTab(2) },
|
||||||
|
text = { Text("Console") },
|
||||||
|
icon = { Icon(Icons.Default.Terminal, contentDescription = null, modifier = Modifier.size(18.dp)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
when (state.selectedTab) {
|
||||||
|
0 -> InfoTab(state)
|
||||||
|
1 -> MetricsTab(state)
|
||||||
|
2 -> ConsoleTab(state, viewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InfoTab(state: ServerDetailUiState) {
|
||||||
|
val server = state.server ?: return
|
||||||
|
val metrics = state.metrics
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
val statusColor = when (server.status) {
|
||||||
|
"online" -> Green500
|
||||||
|
"offline" -> Red500
|
||||||
|
"disabled" -> Grey500
|
||||||
|
else -> Orange500
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Dns,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = server.name,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Surface(
|
||||||
|
color = statusColor.copy(alpha = 0.15f),
|
||||||
|
shape = RoundedCornerShape(8.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.background(statusColor)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = server.status.replaceFirstChar { it.uppercase() },
|
||||||
|
color = statusColor,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
InfoRow("IP Address", server.ip_address)
|
||||||
|
InfoRow("SSH Port", server.ssh_port.toString())
|
||||||
|
if (server.group_name != null) InfoRow("Group", server.group_name)
|
||||||
|
if (server.description != null) InfoRow("Description", server.description)
|
||||||
|
if (server.created_by_name != null) InfoRow("Created By", server.created_by_name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
if (metrics != null) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Memory,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Orange500
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Current Metrics",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
StatCard(
|
||||||
|
"CPU", "${metrics.cpu_usage?.toInt() ?: 0}%", Orange500,
|
||||||
|
icon = Icons.Default.Memory, modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
StatCard(
|
||||||
|
"RAM", "${metrics.ram_usage?.toInt() ?: 0}%", Blue700,
|
||||||
|
icon = Icons.Default.Storage, modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
StatCard(
|
||||||
|
"Disk", "${metrics.disk_usage?.toInt() ?: 0}%", Green500,
|
||||||
|
icon = Icons.Default.SdStorage, modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metrics.uptime != null) {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(Icons.Default.Schedule, contentDescription = null, tint = Grey500, modifier = Modifier.size(16.dp))
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text("Uptime: ${metrics.uptime}", style = MaterialTheme.typography.bodySmall, color = Grey700)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (metrics.load_average != null) {
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(Icons.Default.TrendingUp, contentDescription = null, tint = Grey500, modifier = Modifier.size(16.dp))
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text("Load: ${metrics.load_average}", style = MaterialTheme.typography.bodySmall, color = Grey700)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InfoRow(label: String, value: String) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = Grey700,
|
||||||
|
modifier = Modifier.weight(0.4f)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
modifier = Modifier.weight(0.6f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(color = Grey100, thickness = 0.5.dp)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MetricsTab(state: ServerDetailUiState) {
|
||||||
|
if (state.metricsHistory.isEmpty()) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.StackedLineChart,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Last 24 Hours",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
MetricsChart(
|
||||||
|
points = state.metricsHistory.takeLast(24),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Recent Values",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
state.metricsHistory.takeLast(12).reversed().forEach { point ->
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 3.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(12.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = point.recorded_at?.substringAfterLast(" ")?.substringBeforeLast(":") ?: "",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = Grey500,
|
||||||
|
modifier = Modifier.weight(0.25f)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${point.cpu_usage?.toInt() ?: 0}%",
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = Orange500,
|
||||||
|
modifier = Modifier.weight(0.25f)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${point.ram_usage?.toInt() ?: 0}%",
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = Blue700,
|
||||||
|
modifier = Modifier.weight(0.25f)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${point.disk_usage?.toInt() ?: 0}%",
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = Green500,
|
||||||
|
modifier = Modifier.weight(0.25f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ConsoleTab(state: ServerDetailUiState, viewModel: ServerDetailViewModel) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Terminal,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Execute Command",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.commandText,
|
||||||
|
onValueChange = viewModel::updateCommand,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
placeholder = { Text("e.g. uptime, df -h, free -m") },
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !state.isExecuting,
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(Icons.Default.Terminal, contentDescription = null)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.executeCommand() },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = state.commandText.isNotBlank() && !state.isExecuting
|
||||||
|
) {
|
||||||
|
if (state.isExecuting) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.PlayArrow,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text("Execute")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.commandOutput != null) {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = Grey900)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.CheckCircle,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Green500,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = "Output",
|
||||||
|
color = Green500,
|
||||||
|
style = MaterialTheme.typography.labelSmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = state.commandOutput ?: "",
|
||||||
|
color = Green500,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.commandError != null) {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Error,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = "Error",
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.labelSmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = state.commandError ?: "",
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package com.devlab.app.ui.servers
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.devlab.app.data.model.*
|
||||||
|
import com.devlab.app.data.repository.ServerRepository
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class ServerDetailUiState(
|
||||||
|
val isLoading: Boolean = true,
|
||||||
|
val server: Server? = null,
|
||||||
|
val metrics: LatestMetrics? = null,
|
||||||
|
val metricsHistory: List<MetricPoint> = emptyList(),
|
||||||
|
val error: String? = null,
|
||||||
|
val commandText: String = "",
|
||||||
|
val commandOutput: String? = null,
|
||||||
|
val commandError: String? = null,
|
||||||
|
val isExecuting: Boolean = false,
|
||||||
|
val selectedTab: Int = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
class ServerDetailViewModel : ViewModel() {
|
||||||
|
|
||||||
|
private val repository = ServerRepository()
|
||||||
|
private var serverId: Int = 0
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(ServerDetailUiState())
|
||||||
|
val uiState: StateFlow<ServerDetailUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
fun load(id: Int) {
|
||||||
|
serverId = id
|
||||||
|
loadDetail()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadDetail() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
||||||
|
try {
|
||||||
|
val response = repository.getServerDetail(serverId)
|
||||||
|
if (response.success) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
server = response.data?.server,
|
||||||
|
metrics = response.data?.metrics,
|
||||||
|
error = null
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = response.error ?: "Failed to load server details"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = e.message ?: "Network error"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadMetrics() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val response = repository.getServerMetrics(serverId)
|
||||||
|
if (response.success) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
metricsHistory = response.data ?: emptyList()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateCommand(text: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(commandText = text, commandOutput = null, commandError = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun executeCommand() {
|
||||||
|
val command = _uiState.value.commandText.trim()
|
||||||
|
if (command.isBlank()) return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = _uiState.value.copy(isExecuting = true, commandOutput = null, commandError = null)
|
||||||
|
try {
|
||||||
|
val response = repository.executeCommand(serverId, command)
|
||||||
|
if (response.success) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isExecuting = false,
|
||||||
|
commandOutput = response.data?.output ?: "(no output)"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isExecuting = false,
|
||||||
|
commandError = response.error ?: "Command failed"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isExecuting = false,
|
||||||
|
commandError = e.message ?: "Failed to execute command"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectTab(tab: Int) {
|
||||||
|
_uiState.value = _uiState.value.copy(selectedTab = tab)
|
||||||
|
if (tab == 1 && _uiState.value.metricsHistory.isEmpty()) {
|
||||||
|
loadMetrics()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package com.devlab.app.ui.servers
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material.icons.filled.Clear
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.devlab.app.ui.components.LoadingIndicator
|
||||||
|
import com.devlab.app.ui.components.ServerCard
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ServerListScreen(
|
||||||
|
onServerClick: (Int) -> Unit,
|
||||||
|
viewModel: ServerListViewModel = viewModel()
|
||||||
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
LaunchedEffect(listState.canScrollForward) {
|
||||||
|
if (!listState.canScrollForward && !state.isLoadingMore && state.hasMore && state.servers.isNotEmpty()) {
|
||||||
|
viewModel.loadMore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Servers") },
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onPrimary
|
||||||
|
),
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { viewModel.loadServers() }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Refresh,
|
||||||
|
contentDescription = "Refresh",
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Column(modifier = Modifier.padding(padding)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.searchQuery,
|
||||||
|
onValueChange = viewModel::updateSearch,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
placeholder = { Text("Search servers...") },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||||
|
trailingIcon = {
|
||||||
|
if (state.searchQuery.isNotEmpty()) {
|
||||||
|
IconButton(onClick = { viewModel.updateSearch("") }) {
|
||||||
|
Icon(Icons.Default.Clear, contentDescription = "Clear")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
|
||||||
|
when {
|
||||||
|
state.isLoading -> LoadingIndicator()
|
||||||
|
state.error != null -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
text = state.error ?: "",
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodyLarge
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
OutlinedButton(onClick = { viewModel.loadServers() }) {
|
||||||
|
Text("Retry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.servers.isEmpty() -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "No servers found",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
items(state.servers, key = { it.id }) { server ->
|
||||||
|
ServerCard(
|
||||||
|
server = server,
|
||||||
|
onClick = { onServerClick(server.id) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.isLoadingMore) {
|
||||||
|
item {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package com.devlab.app.ui.servers
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.devlab.app.data.model.Server
|
||||||
|
import com.devlab.app.data.repository.ServerRepository
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class ServerListUiState(
|
||||||
|
val isLoading: Boolean = true,
|
||||||
|
val servers: List<Server> = emptyList(),
|
||||||
|
val error: String? = null,
|
||||||
|
val searchQuery: String = "",
|
||||||
|
val currentPage: Int = 1,
|
||||||
|
val isLoadingMore: Boolean = false,
|
||||||
|
val hasMore: Boolean = true
|
||||||
|
)
|
||||||
|
|
||||||
|
class ServerListViewModel : ViewModel() {
|
||||||
|
|
||||||
|
private val repository = ServerRepository()
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(ServerListUiState())
|
||||||
|
val uiState: StateFlow<ServerListUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadServers()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadServers() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = true,
|
||||||
|
error = null,
|
||||||
|
currentPage = 1,
|
||||||
|
servers = emptyList()
|
||||||
|
)
|
||||||
|
fetchServers(page = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadMore() {
|
||||||
|
val state = _uiState.value
|
||||||
|
if (state.isLoadingMore || !state.hasMore) return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = state.copy(isLoadingMore = true)
|
||||||
|
fetchServers(page = state.currentPage + 1, append = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateSearch(query: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(searchQuery = query)
|
||||||
|
loadServers()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchServers(page: Int, append: Boolean = false) {
|
||||||
|
try {
|
||||||
|
val search = _uiState.value.searchQuery.takeIf { it.isNotBlank() }
|
||||||
|
val response = repository.getServers(page = page, search = search)
|
||||||
|
if (response.success) {
|
||||||
|
val newList = if (append) {
|
||||||
|
_uiState.value.servers + (response.data ?: emptyList())
|
||||||
|
} else {
|
||||||
|
response.data ?: emptyList()
|
||||||
|
}
|
||||||
|
val pagination = response.pagination
|
||||||
|
val hasMore = pagination != null && page < pagination.total_pages
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
isLoadingMore = false,
|
||||||
|
servers = newList,
|
||||||
|
currentPage = page,
|
||||||
|
hasMore = hasMore,
|
||||||
|
error = null
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
isLoadingMore = false,
|
||||||
|
error = response.error ?: "Failed to load servers"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
isLoadingMore = false,
|
||||||
|
error = e.message ?: "Network error"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
android/app/src/main/java/com/devlab/app/ui/theme/Color.kt
Normal file
29
android/app/src/main/java/com/devlab/app/ui/theme/Color.kt
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package com.devlab.app.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
val Blue900 = Color(0xFF0D47A1)
|
||||||
|
val Blue700 = Color(0xFF1565C0)
|
||||||
|
val Blue500 = Color(0xFF2196F3)
|
||||||
|
val Cyan500 = Color(0xFF00BCD4)
|
||||||
|
val Green500 = Color(0xFF4CAF50)
|
||||||
|
val Red500 = Color(0xFFF44336)
|
||||||
|
val Orange500 = Color(0xFFFF9800)
|
||||||
|
val Grey50 = Color(0xFFF5F5F5)
|
||||||
|
val Grey100 = Color(0xFFE0E0E0)
|
||||||
|
val Grey300 = Color(0xFFBDBDBD)
|
||||||
|
val Grey500 = Color(0xFF9E9E9E)
|
||||||
|
val Grey700 = Color(0xFF616161)
|
||||||
|
val Grey900 = Color(0xFF212121)
|
||||||
|
val White = Color(0xFFFFFFFF)
|
||||||
|
val SurfaceLight = Color(0xFFFCFCFC)
|
||||||
|
val CardSurface = Color(0xFFFFFFFF)
|
||||||
|
|
||||||
|
val Blue800 = Color(0xFF0D47A1)
|
||||||
|
val Blue600 = Color(0xFF1E88E5)
|
||||||
|
val Purple500 = Color(0xFF7C4DFF)
|
||||||
|
|
||||||
|
val DarkSurface = Color(0xFF1A1C1E)
|
||||||
|
val DarkSurfaceVariant = Color(0xFF2A2C2E)
|
||||||
|
val DarkCardSurface = Color(0xFF252729)
|
||||||
|
val DarkBackground = Color(0xFF121416)
|
||||||
51
android/app/src/main/java/com/devlab/app/ui/theme/Theme.kt
Normal file
51
android/app/src/main/java/com/devlab/app/ui/theme/Theme.kt
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package com.devlab.app.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
|
||||||
|
private val LightColorScheme = lightColorScheme(
|
||||||
|
primary = Blue700,
|
||||||
|
onPrimary = White,
|
||||||
|
primaryContainer = Blue500,
|
||||||
|
secondary = Cyan500,
|
||||||
|
tertiary = Purple500,
|
||||||
|
background = Grey50,
|
||||||
|
surface = SurfaceLight,
|
||||||
|
surfaceVariant = Grey100,
|
||||||
|
error = Red500,
|
||||||
|
onBackground = Grey900,
|
||||||
|
onSurface = Grey900,
|
||||||
|
onSurfaceVariant = Grey700,
|
||||||
|
outline = Grey300
|
||||||
|
)
|
||||||
|
|
||||||
|
private val DarkColorScheme = darkColorScheme(
|
||||||
|
primary = Blue500,
|
||||||
|
onPrimary = White,
|
||||||
|
primaryContainer = Blue700,
|
||||||
|
secondary = Cyan500,
|
||||||
|
tertiary = Purple500,
|
||||||
|
background = DarkBackground,
|
||||||
|
surface = DarkSurface,
|
||||||
|
surfaceVariant = DarkSurfaceVariant,
|
||||||
|
error = Red500,
|
||||||
|
onBackground = White,
|
||||||
|
onSurface = White,
|
||||||
|
onSurfaceVariant = Grey300,
|
||||||
|
outline = Grey500
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ServerManagerTheme(
|
||||||
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
|
||||||
|
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = colorScheme,
|
||||||
|
typography = Typography,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
49
android/app/src/main/java/com/devlab/app/ui/theme/Type.kt
Normal file
49
android/app/src/main/java/com/devlab/app/ui/theme/Type.kt
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package com.devlab.app.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
val Typography = Typography(
|
||||||
|
headlineLarge = TextStyle(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 28.sp,
|
||||||
|
lineHeight = 36.sp
|
||||||
|
),
|
||||||
|
headlineMedium = TextStyle(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 22.sp,
|
||||||
|
lineHeight = 28.sp
|
||||||
|
),
|
||||||
|
titleLarge = TextStyle(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
lineHeight = 24.sp
|
||||||
|
),
|
||||||
|
titleMedium = TextStyle(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
lineHeight = 22.sp
|
||||||
|
),
|
||||||
|
bodyLarge = TextStyle(
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
lineHeight = 24.sp
|
||||||
|
),
|
||||||
|
bodyMedium = TextStyle(
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 20.sp
|
||||||
|
),
|
||||||
|
labelLarge = TextStyle(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 20.sp
|
||||||
|
),
|
||||||
|
labelSmall = TextStyle(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
lineHeight = 16.sp
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.devlab.app.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
private val Context.dataStore by preferencesDataStore(name = "servermanager_prefs")
|
||||||
|
|
||||||
|
class PreferencesManager(private val context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val KEY_TOKEN = stringPreferencesKey("api_token")
|
||||||
|
private val KEY_USERNAME = stringPreferencesKey("username")
|
||||||
|
private val KEY_EMAIL = stringPreferencesKey("email")
|
||||||
|
private val KEY_ROLE = stringPreferencesKey("role")
|
||||||
|
private val KEY_USER_ID = intPreferencesKey("user_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
val token: Flow<String> = context.dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_TOKEN] ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
val username: Flow<String> = context.dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_USERNAME] ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
val email: Flow<String> = context.dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_EMAIL] ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
val role: Flow<String> = context.dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_ROLE] ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
val userId: Flow<Int> = context.dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_USER_ID] ?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveCredentials(token: String, username: String) {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_TOKEN] = token
|
||||||
|
prefs[KEY_USERNAME] = username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveUserData(token: String, id: Int, username: String, email: String, role: String) {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_TOKEN] = token
|
||||||
|
prefs[KEY_USER_ID] = id
|
||||||
|
prefs[KEY_USERNAME] = username
|
||||||
|
prefs[KEY_EMAIL] = email
|
||||||
|
prefs[KEY_ROLE] = role
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveProfile(email: String) {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_EMAIL] = email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getToken(): String = context.dataStore.data.first()[KEY_TOKEN] ?: ""
|
||||||
|
suspend fun getUsername(): String = context.dataStore.data.first()[KEY_USERNAME] ?: ""
|
||||||
|
suspend fun getEmail(): String = context.dataStore.data.first()[KEY_EMAIL] ?: ""
|
||||||
|
|
||||||
|
suspend fun clear() {
|
||||||
|
context.dataStore.edit { it.clear() }
|
||||||
|
}
|
||||||
|
}
|
||||||
60
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
60
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<!-- Server rack body -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#1565C0"
|
||||||
|
android:pathData="M34,30 L74,30 C76.2,30 78,31.8 78,34 L78,74 C78,76.2 76.2,78 74,78 L34,78 C31.8,78 30,76.2 30,74 L30,34 C30,31.8 31.8,30 34,30Z" />
|
||||||
|
<!-- Top server unit -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M38,36 L70,36 L70,44 L38,44Z"
|
||||||
|
android:fillAlpha="0.9" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#1565C0"
|
||||||
|
android:pathData="M42,38 L50,38 L50,42 L42,42Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#1565C0"
|
||||||
|
android:pathData="M54,38 L62,38 L62,42 L54,42Z" />
|
||||||
|
<!-- Middle server unit -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M38,48 L70,48 L70,56 L38,56Z"
|
||||||
|
android:fillAlpha="0.9" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#1565C0"
|
||||||
|
android:pathData="M42,50 L50,50 L50,54 L42,54Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#1565C0"
|
||||||
|
android:pathData="M54,50 L62,50 L62,54 L54,54Z" />
|
||||||
|
<!-- Bottom server unit -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M38,60 L70,60 L70,68 L38,68Z"
|
||||||
|
android:fillAlpha="0.9" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#1565C0"
|
||||||
|
android:pathData="M42,62 L50,62 L50,66 L42,66Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#1565C0"
|
||||||
|
android:pathData="M54,62 L62,62 L62,66 L54,66Z" />
|
||||||
|
<!-- Status LEDs -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#4CAF50"
|
||||||
|
android:pathData="M66,40 A2,2 0,1,1 66,39.99Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#4CAF50"
|
||||||
|
android:pathData="M66,52 A2,2 0,1,1 66,51.99Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#4CAF50"
|
||||||
|
android:pathData="M66,64 A2,2 0,1,1 66,63.99Z" />
|
||||||
|
<!-- Decorative line top -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M40,72 L68,72"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:strokeColor="#FFFFFF" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/primary" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
14
android/app/src/main/res/values/colors.xml
Normal file
14
android/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="primary">#1565C0</color>
|
||||||
|
<color name="primary_dark">#0D47A1</color>
|
||||||
|
<color name="accent">#00BCD4</color>
|
||||||
|
<color name="background">#F5F5F5</color>
|
||||||
|
<color name="surface">#FFFFFF</color>
|
||||||
|
<color name="error">#D32F2F</color>
|
||||||
|
<color name="on_primary">#FFFFFF</color>
|
||||||
|
<color name="online">#4CAF50</color>
|
||||||
|
<color name="offline">#F44336</color>
|
||||||
|
<color name="warning">#FF9800</color>
|
||||||
|
<color name="splash_background">#1565C0</color>
|
||||||
|
</resources>
|
||||||
5
android/app/src/main/res/values/strings.xml
Normal file
5
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">ServerManager</string>
|
||||||
|
<string name="splash_subtitle">Server Management Platform</string>
|
||||||
|
</resources>
|
||||||
12
android/app/src/main/res/values/themes.xml
Normal file
12
android/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.ServerManager" parent="android:Theme.Material.Light.NoActionBar">
|
||||||
|
<item name="android:statusBarColor">@color/primary_dark</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="Theme.ServerManager.Splash" parent="Theme.SplashScreen">
|
||||||
|
<item name="windowSplashScreenBackground">@color/splash_background</item>
|
||||||
|
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_launcher_foreground</item>
|
||||||
|
<item name="postSplashScreenTheme">@style/Theme.ServerManager</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
5
android/build.gradle.kts
Normal file
5
android/build.gradle.kts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application") version "9.2.1" apply false
|
||||||
|
id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" apply false
|
||||||
|
id("org.jetbrains.kotlin.plugin.serialization") version "2.3.21" apply false
|
||||||
|
}
|
||||||
4
android/gradle.properties
Normal file
4
android/gradle.properties
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
154
android/gradlew
vendored
Executable file
154
android/gradlew
vendored
Executable file
@@ -0,0 +1,154 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced functionality shells and target
|
||||||
|
# temporary focusing, current function.
|
||||||
|
#
|
||||||
|
# (2) You need a Java installation to run Gradle. If JAVA_HOME is set, it
|
||||||
|
# will be used. Otherwise, java from PATH will be used.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld -- "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in
|
||||||
|
/*) app_path=$link ;;
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in
|
||||||
|
CYGWIN* ) cygwin=true ;;
|
||||||
|
Darwin* ) darwin=true ;;
|
||||||
|
MSYS* | MINGW* ) msys=true ;;
|
||||||
|
NonStop* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in
|
||||||
|
max*)
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
case $MAX_FD in
|
||||||
|
'' | soft) :;;
|
||||||
|
*)
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stracks://processed items.
|
||||||
|
# shellcheck disable=SC2153
|
||||||
|
case $TERM in
|
||||||
|
dumb | '' ) : ;;
|
||||||
|
* ) eval `resize 2>/dev/null` ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Collect all arguments for the java command;
|
||||||
|
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
|
||||||
|
# shell script including quotes and/or backslashes, so put them in
|
||||||
|
# temporary files to avoid running into problems with process substitution.
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
org.gradle.wrapper.GradleWrapperMain \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xeli" is not available.
|
||||||
|
if ! "$cygwin" && ! "$msys" && ! "$nonstop" ; then
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
22
android/settings.gradle.kts
Normal file
22
android/settings.gradle.kts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google {
|
||||||
|
content {
|
||||||
|
includeGroupByRegex("com\\.android.*")
|
||||||
|
includeGroupByRegex("com\\.google.*")
|
||||||
|
includeGroupByRegex("androidx.*")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "ServerManager"
|
||||||
|
include(":app")
|
||||||
BIN
public/sysadmin.apk
Normal file
BIN
public/sysadmin.apk
Normal file
Binary file not shown.
@@ -101,5 +101,9 @@ $router->put('/api/servers/:id', [ApiController::class, 'serverUpdate']);
|
|||||||
$router->delete('/api/servers/:id', [ApiController::class, 'serverDelete']);
|
$router->delete('/api/servers/:id', [ApiController::class, 'serverDelete']);
|
||||||
$router->get('/api/servers/:id/metrics', [ApiController::class, 'serverMetrics']);
|
$router->get('/api/servers/:id/metrics', [ApiController::class, 'serverMetrics']);
|
||||||
$router->post('/api/servers/:id/execute', [ApiController::class, 'executeCommand']);
|
$router->post('/api/servers/:id/execute', [ApiController::class, 'executeCommand']);
|
||||||
|
$router->post('/api/auth/login', [ApiController::class, 'login']);
|
||||||
|
$router->post('/api/auth/register', [ApiController::class, 'register']);
|
||||||
$router->get('/api/users', [ApiController::class, 'users']);
|
$router->get('/api/users', [ApiController::class, 'users']);
|
||||||
|
$router->get('/api/profile', [ApiController::class, 'profile']);
|
||||||
|
$router->put('/api/profile', [ApiController::class, 'updateProfile']);
|
||||||
$router->get('/api/refresh-metrics', [ApiController::class, 'refreshAllMetrics']);
|
$router->get('/api/refresh-metrics', [ApiController::class, 'refreshAllMetrics']);
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ namespace ServerManager\Controllers;
|
|||||||
|
|
||||||
use ServerManager\Core\App;
|
use ServerManager\Core\App;
|
||||||
use ServerManager\Core\Session;
|
use ServerManager\Core\Session;
|
||||||
use ServerManager\Core\Validator;
|
|
||||||
use ServerManager\Models\Server;
|
use ServerManager\Models\Server;
|
||||||
use ServerManager\Models\User;
|
use ServerManager\Models\User;
|
||||||
use ServerManager\Services\MonitoringService;
|
use ServerManager\Services\MonitoringService;
|
||||||
use ServerManager\Services\SSHService;
|
use ServerManager\Services\SSHService;
|
||||||
use ServerManager\Services\AuditService;
|
use ServerManager\Services\AuditService;
|
||||||
use ServerManager\Models\CommandHistory;
|
use ServerManager\Models\CommandHistory;
|
||||||
|
use ServerManager\Core\Validator;
|
||||||
|
|
||||||
class ApiController
|
class ApiController
|
||||||
{
|
{
|
||||||
@@ -305,4 +305,219 @@ class ApiController
|
|||||||
'data' => $results,
|
'data' => $results,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?? $_POST;
|
||||||
|
|
||||||
|
$validator = new Validator();
|
||||||
|
$rules = [
|
||||||
|
'username' => 'required|min:3|max:50',
|
||||||
|
'email' => 'required|email',
|
||||||
|
'password' => 'required|min:8',
|
||||||
|
'confirm_password' => 'required',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!$validator->validate($input, $rules)) {
|
||||||
|
$this->view->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => $validator->getFirstError(),
|
||||||
|
], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($input['password'] !== $input['confirm_password']) {
|
||||||
|
$this->view->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Passwords do not match.',
|
||||||
|
], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userModel = new User();
|
||||||
|
|
||||||
|
$existing = $userModel->findByUsername($input['username']);
|
||||||
|
if ($existing) {
|
||||||
|
$this->view->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Username is already taken.',
|
||||||
|
], 409);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingEmail = $userModel->findByEmail($input['email']);
|
||||||
|
if ($existingEmail) {
|
||||||
|
$this->view->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Email is already registered.',
|
||||||
|
], 409);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = $userModel->create([
|
||||||
|
'username' => $input['username'],
|
||||||
|
'email' => $input['email'],
|
||||||
|
'password' => $input['password'],
|
||||||
|
'role' => 'admin',
|
||||||
|
'is_active' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = $userModel->findById($userId);
|
||||||
|
|
||||||
|
$auditService = new AuditService();
|
||||||
|
$auditService->log('user_registered', 'User registered via API', $userId);
|
||||||
|
|
||||||
|
$this->view->json([
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'token' => $user['api_token'],
|
||||||
|
'user' => [
|
||||||
|
'id' => (int) $user['id'],
|
||||||
|
'username' => $user['username'],
|
||||||
|
'email' => $user['email'],
|
||||||
|
'role' => $user['role'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function profile(): void
|
||||||
|
{
|
||||||
|
$user = $this->authenticateRequest();
|
||||||
|
|
||||||
|
$this->view->json([
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'id' => (int) $user['id'],
|
||||||
|
'username' => $user['username'],
|
||||||
|
'email' => $user['email'],
|
||||||
|
'role' => $user['role'],
|
||||||
|
'created_at' => $user['created_at'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateProfile(): void
|
||||||
|
{
|
||||||
|
$user = $this->authenticateRequest();
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?? $_POST;
|
||||||
|
|
||||||
|
$validator = new Validator();
|
||||||
|
$rules = [
|
||||||
|
'email' => 'required|email',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!$validator->validate($input, $rules)) {
|
||||||
|
$this->view->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => $validator->getFirstError(),
|
||||||
|
], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$updateData = ['email' => $input['email']];
|
||||||
|
|
||||||
|
if (!empty($input['new_password'])) {
|
||||||
|
if (strlen($input['new_password']) < 8) {
|
||||||
|
$this->view->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'New password must be at least 8 characters.',
|
||||||
|
], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (empty($input['current_password'])) {
|
||||||
|
$this->view->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Current password is required to set a new password.',
|
||||||
|
], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($input['new_password'] !== ($input['confirm_password'] ?? '')) {
|
||||||
|
$this->view->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'New passwords do not match.',
|
||||||
|
], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userModel = new User();
|
||||||
|
$fullUser = $userModel->findById((int) $user['id']);
|
||||||
|
if (!$fullUser || !(new \ServerManager\Core\Security())->verifyPassword($input['current_password'], $fullUser['password_hash'])) {
|
||||||
|
$this->view->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Current password is incorrect.',
|
||||||
|
], 403);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$updateData['password'] = $input['new_password'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$userModel = new User();
|
||||||
|
$userModel->update((int) $user['id'], $updateData);
|
||||||
|
|
||||||
|
$auditService = new AuditService();
|
||||||
|
$auditService->log('profile_updated', 'Profile updated via API', (int) $user['id']);
|
||||||
|
|
||||||
|
$this->view->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Profile updated successfully.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function login(): void
|
||||||
|
{
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?? $_POST;
|
||||||
|
|
||||||
|
$validator = new Validator();
|
||||||
|
$rules = [
|
||||||
|
'username' => 'required',
|
||||||
|
'password' => 'required|min:6',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!$validator->validate($input, $rules)) {
|
||||||
|
$this->view->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => $validator->getFirstError(),
|
||||||
|
], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userModel = new User();
|
||||||
|
$user = $userModel->authenticate($input['username'], $input['password']);
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
$auditService = new AuditService();
|
||||||
|
$auditService->logLogin($input['username'], false, 'Invalid credentials');
|
||||||
|
$this->view->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Invalid username or password.',
|
||||||
|
], 401);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = $user['api_token'];
|
||||||
|
if (empty($token)) {
|
||||||
|
$security = new \ServerManager\Core\Security();
|
||||||
|
$token = $security->generateApiToken();
|
||||||
|
$userModel->update((int) $user['id'], ['api_token' => $token]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$auditService = new AuditService();
|
||||||
|
$auditService->logLogin($input['username'], true);
|
||||||
|
|
||||||
|
$this->view->json([
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'token' => $token,
|
||||||
|
'user' => [
|
||||||
|
'id' => (int) $user['id'],
|
||||||
|
'username' => $user['username'],
|
||||||
|
'email' => $user['email'],
|
||||||
|
'role' => $user['role'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,13 @@ $recentActivity = $recentActivity ?? [];
|
|||||||
<span class="stat-label">Avg Disk</span>
|
<span class="stat-label">Avg Disk</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<a href="/sysadmin.apk" class="stat-card stat-card-info" download style="text-decoration:none; cursor:pointer;">
|
||||||
|
<div class="stat-icon"><i class="fas fa-download"></i></div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<span class="stat-value">v1.0</span>
|
||||||
|
<span class="stat-label">Android App</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="dashboard-grid">
|
<div class="dashboard-grid">
|
||||||
|
|||||||
@@ -76,6 +76,14 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<li class="nav-divider"></li>
|
||||||
|
<li class="nav-section">Mobile</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a href="/sysadmin.apk" class="nav-link" download>
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
<span>Download APK</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
|
|||||||
Reference in New Issue
Block a user