diff --git a/.gitignore b/.gitignore
index 547bc9a..555a420 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,4 +3,7 @@
.idea/
logs/
*.log
-android/
+android/.gradle/
+android/build/
+android/app/build/
+android/local.properties
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
new file mode 100644
index 0000000..e8ca6aa
--- /dev/null
+++ b/android/app/build.gradle.kts
@@ -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")
+}
diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro
new file mode 100644
index 0000000..a1971ab
--- /dev/null
+++ b/android/app/proguard-rules.pro
@@ -0,0 +1,5 @@
+# Project-wide ProGuard rules
+-keepattributes Signature
+-keepattributes *Annotation*
+-keep class kotlinx.serialization.** { *; }
+-keepclassmembers class com.devlab.app.data.model.** { *; }
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..0f6f5a9
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/java/com/devlab/app/MainActivity.kt b/android/app/src/main/java/com/devlab/app/MainActivity.kt
new file mode 100644
index 0000000..492b47e
--- /dev/null
+++ b/android/app/src/main/java/com/devlab/app/MainActivity.kt
@@ -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() }
+ )
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/devlab/app/ServerManagerApp.kt b/android/app/src/main/java/com/devlab/app/ServerManagerApp.kt
new file mode 100644
index 0000000..4a288b3
--- /dev/null
+++ b/android/app/src/main/java/com/devlab/app/ServerManagerApp.kt
@@ -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
+ }
+}
diff --git a/android/app/src/main/java/com/devlab/app/data/api/ApiService.kt b/android/app/src/main/java/com/devlab/app/data/api/ApiService.kt
new file mode 100644
index 0000000..2a96e41
--- /dev/null
+++ b/android/app/src/main/java/com/devlab/app/data/api/ApiService.kt
@@ -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
+
+ @PUT("api/profile")
+ suspend fun updateProfile(@Body request: ProfileUpdateRequest): ApiResponse