Rediseño completo app Android + registro/perfil API #18
5
.gitignore
vendored
5
.gitignore
vendored
@@ -3,4 +3,7 @@
|
||||
.idea/
|
||||
logs/
|
||||
*.log
|
||||
android/
|
||||
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")
|
||||
Binary file not shown.
@@ -102,5 +102,8 @@ $router->delete('/api/servers/:id', [ApiController::class, 'serverDelete']);
|
||||
$router->get('/api/servers/:id/metrics', [ApiController::class, 'serverMetrics']);
|
||||
$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/profile', [ApiController::class, 'profile']);
|
||||
$router->put('/api/profile', [ApiController::class, 'updateProfile']);
|
||||
$router->get('/api/refresh-metrics', [ApiController::class, 'refreshAllMetrics']);
|
||||
|
||||
@@ -306,6 +306,166 @@ class ApiController
|
||||
]);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user