Merge pull request 'Rediseño completo app Android + registro/perfil API' (#18) from feat/android-app-redesign into master

Reviewed-on: #18
Reviewed-by: rafaga21 <rafaelminaya20@hotmail.com>
This commit was merged in pull request #18.
This commit is contained in:
2026-06-09 16:46:37 -04:00
56 changed files with 4145 additions and 1 deletions

5
.gitignore vendored
View File

@@ -3,4 +3,7 @@
.idea/
logs/
*.log
android/
android/.gradle/
android/build/
android/app/build/
android/local.properties

View 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
View File

@@ -0,0 +1,5 @@
# Project-wide ProGuard rules
-keepattributes Signature
-keepattributes *Annotation*
-keep class kotlinx.serialization.** { *; }
-keepclassmembers class com.devlab.app.data.model.** { *; }

View 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>

View 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() }
)
}
}
}
}

View 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
}
}

View File

@@ -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?>>
}

View File

@@ -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
}
}

View File

@@ -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()
}

View File

@@ -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
}
}

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -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()
}
}

View File

@@ -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)
}
}
}

View File

@@ -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))
}
}

View File

@@ -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"
}
}

View File

@@ -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()
}
}
}

View File

@@ -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)
}
}

View File

@@ -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
)
}
}

View File

@@ -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
)
}
}
}

View File

@@ -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)
)
}
}
}
}
}
}

View File

@@ -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
)
}
}
}
}

View 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))
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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)
}
}
}
}
}

View File

@@ -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"
)
}
)
}
}
}

View File

@@ -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")
}
}
}

View File

@@ -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"
)
}
)
}
}
}

View File

@@ -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
)
}
}
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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()
}
}
}
}
}
}
}
}
}

View File

@@ -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"
)
}
}
}

View 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)

View 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
)
}

View 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
)
)

View File

@@ -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() }
}
}

View 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>

View File

@@ -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>

View 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>

View 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>

View 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
View 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
}

View File

@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

Binary file not shown.

View 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
View 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" "$@"

View 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.

View File

@@ -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']);

View File

@@ -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;