This commit is contained in:
2026-01-12 14:07:48 +01:00
commit a910959353
27 changed files with 2024 additions and 0 deletions

109
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,109 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.serialization")
}
android {
namespace = "com.watcher.mobile"
compileSdk = 34
defaultConfig {
applicationId = "com.watcher.mobile"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
// API Base URL - kann über Build Config überschrieben werden
buildConfigField("String", "API_BASE_URL", "\"http://localhost:8080/api/\"")
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
debug {
isDebuggable = true
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.4"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
// Core Android
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("androidx.activity:activity-compose:1.8.2")
// Compose
implementation(platform("androidx.compose:compose-bom:2024.01.00"))
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.navigation:navigation-compose:2.7.6")
// ViewModel
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
// Networking - Retrofit + OkHttp
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
// JSON Serialization
implementation("com.google.code.gson:gson:2.10.1")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
// DataStore for preferences
implementation("androidx.datastore:datastore-preferences:1.0.0")
// Testing
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation(platform("androidx.compose:compose-bom:2024.01.00"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}

32
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,32 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.kts.
# Retrofit
-keepattributes Signature, InnerClasses, EnclosingMethod
-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations
-keepclassmembers,allowshrinking,allowobfuscation interface * {
@retrofit2.http.* <methods>;
}
-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
-dontwarn javax.annotation.**
-dontwarn kotlin.Unit
-dontwarn retrofit2.KotlinExtensions
-dontwarn retrofit2.KotlinExtensions$*
# OkHttp
-dontwarn okhttp3.**
-dontwarn okio.**
# Gson
-keepattributes Signature
-keepattributes *Annotation*
-dontwarn sun.misc.**
-keep class com.google.gson.** { *; }
-keep class * implements com.google.gson.TypeAdapter
-keep class * implements com.google.gson.TypeAdapterFactory
-keep class * implements com.google.gson.JsonSerializer
-keep class * implements com.google.gson.JsonDeserializer
# Keep data classes
-keep class com.watcher.mobile.data.** { *; }

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Internet Permission für API Calls -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".WatcherApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.WatcherMobile"
android:usesCleartextTraffic="true"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.WatcherMobile">
<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,143 @@
package com.watcher.mobile
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
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.res.stringResource
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.watcher.mobile.ui.AgentsScreen
import com.watcher.mobile.ui.DashboardScreen
import com.watcher.mobile.ui.theme.WatcherMobileTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
WatcherMobileTheme {
WatcherApp()
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WatcherApp() {
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.app_name)) },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary
)
)
},
bottomBar = {
NavigationBar {
NavigationBarItem(
icon = { Icon(Icons.Default.Dashboard, contentDescription = null) },
label = { Text(stringResource(R.string.nav_dashboard)) },
selected = currentDestination?.hierarchy?.any { it.route == "dashboard" } == true,
onClick = {
navController.navigate("dashboard") {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
)
NavigationBarItem(
icon = { Icon(Icons.Default.Computer, contentDescription = null) },
label = { Text(stringResource(R.string.nav_agents)) },
selected = currentDestination?.hierarchy?.any { it.route == "agents" } == true,
onClick = {
navController.navigate("agents") {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
)
NavigationBarItem(
icon = { Icon(Icons.Default.Notifications, contentDescription = null) },
label = { Text(stringResource(R.string.nav_alerts)) },
selected = currentDestination?.hierarchy?.any { it.route == "alerts" } == true,
onClick = {
navController.navigate("alerts") {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
)
NavigationBarItem(
icon = { Icon(Icons.Default.Settings, contentDescription = null) },
label = { Text(stringResource(R.string.nav_settings)) },
selected = currentDestination?.hierarchy?.any { it.route == "settings" } == true,
onClick = {
navController.navigate("settings") {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
)
}
}
) { innerPadding ->
NavHost(
navController = navController,
startDestination = "dashboard",
modifier = Modifier.padding(innerPadding)
) {
composable("dashboard") {
DashboardScreen()
}
composable("agents") {
AgentsScreen()
}
composable("alerts") {
// TODO: Implement AlertsScreen
PlaceholderScreen(stringResource(R.string.nav_alerts))
}
composable("settings") {
// TODO: Implement SettingsScreen
PlaceholderScreen(stringResource(R.string.nav_settings))
}
}
}
}
@Composable
fun PlaceholderScreen(title: String) {
Surface(modifier = Modifier.padding()) {
Text(
text = "$title - Coming Soon",
style = MaterialTheme.typography.headlineMedium
)
}
}

View File

@@ -0,0 +1,46 @@
package com.watcher.mobile
import android.app.Application
import com.watcher.mobile.network.RetrofitClient
import com.watcher.mobile.utils.PreferencesManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
/**
* Application Class für globale Initialisierung
*/
class WatcherApplication : Application() {
lateinit var preferencesManager: PreferencesManager
private set
override fun onCreate() {
super.onCreate()
// Preferences Manager initialisieren
preferencesManager = PreferencesManager(this)
// Retrofit mit gespeicherten Einstellungen initialisieren
initializeRetrofitClient()
}
private fun initializeRetrofitClient() {
CoroutineScope(Dispatchers.IO).launch {
val apiUrl = preferencesManager.apiBaseUrl.first()
val apiKey = preferencesManager.apiKey.first()
if (!apiUrl.isNullOrEmpty()) {
RetrofitClient.initialize(apiUrl, apiKey)
} else {
// Default URL aus BuildConfig verwenden
RetrofitClient.initialize(BuildConfig.API_BASE_URL, null)
}
}
}
companion object {
const val TAG = "WatcherApp"
}
}

View File

@@ -0,0 +1,131 @@
package com.watcher.mobile.data
import com.google.gson.annotations.SerializedName
/**
* Datenmodelle für die Monitoring API
*/
// Agent Status
data class Agent(
@SerializedName("id")
val id: String,
@SerializedName("name")
val name: String,
@SerializedName("status")
val status: AgentStatus,
@SerializedName("hostname")
val hostname: String?,
@SerializedName("ipAddress")
val ipAddress: String?,
@SerializedName("lastSeen")
val lastSeen: Long,
@SerializedName("version")
val version: String?,
@SerializedName("metrics")
val metrics: AgentMetrics?
)
enum class AgentStatus {
@SerializedName("online")
ONLINE,
@SerializedName("offline")
OFFLINE,
@SerializedName("warning")
WARNING,
@SerializedName("error")
ERROR
}
data class AgentMetrics(
@SerializedName("cpuUsage")
val cpuUsage: Float?,
@SerializedName("memoryUsage")
val memoryUsage: Float?,
@SerializedName("diskUsage")
val diskUsage: Float?,
@SerializedName("networkIn")
val networkIn: Long?,
@SerializedName("networkOut")
val networkOut: Long?
)
// Alert
data class Alert(
@SerializedName("id")
val id: String,
@SerializedName("agentId")
val agentId: String?,
@SerializedName("title")
val title: String,
@SerializedName("message")
val message: String,
@SerializedName("severity")
val severity: AlertSeverity,
@SerializedName("timestamp")
val timestamp: Long,
@SerializedName("acknowledged")
val acknowledged: Boolean = false
)
enum class AlertSeverity {
@SerializedName("info")
INFO,
@SerializedName("warning")
WARNING,
@SerializedName("critical")
CRITICAL
}
// Dashboard Summary
data class DashboardSummary(
@SerializedName("totalAgents")
val totalAgents: Int,
@SerializedName("onlineAgents")
val onlineAgents: Int,
@SerializedName("offlineAgents")
val offlineAgents: Int,
@SerializedName("activeAlerts")
val activeAlerts: Int,
@SerializedName("criticalAlerts")
val criticalAlerts: Int,
@SerializedName("recentAlerts")
val recentAlerts: List<Alert>,
@SerializedName("systemHealth")
val systemHealth: SystemHealth?
)
data class SystemHealth(
@SerializedName("status")
val status: String,
@SerializedName("uptime")
val uptime: Long?,
@SerializedName("timestamp")
val timestamp: Long
)
// API Response Wrapper
data class ApiResponse<T>(
@SerializedName("success")
val success: Boolean,
@SerializedName("data")
val data: T?,
@SerializedName("error")
val error: String?,
@SerializedName("timestamp")
val timestamp: Long?
)
// Pagination
data class PaginatedResponse<T>(
@SerializedName("items")
val items: List<T>,
@SerializedName("page")
val page: Int,
@SerializedName("pageSize")
val pageSize: Int,
@SerializedName("totalItems")
val totalItems: Int,
@SerializedName("totalPages")
val totalPages: Int
)

View File

@@ -0,0 +1,62 @@
package com.watcher.mobile.network
import com.watcher.mobile.data.Agent
import com.watcher.mobile.data.Alert
import com.watcher.mobile.data.ApiResponse
import com.watcher.mobile.data.DashboardSummary
import com.watcher.mobile.data.PaginatedResponse
import retrofit2.Response
import retrofit2.http.*
/**
* Retrofit API Service Interface
* Definiert alle API Endpunkte für das Monitoring System
*/
interface ApiService {
// Dashboard
@GET("dashboard")
suspend fun getDashboard(): Response<ApiResponse<DashboardSummary>>
// Agents
@GET("agents")
suspend fun getAgents(
@Query("page") page: Int = 1,
@Query("pageSize") pageSize: Int = 50
): Response<ApiResponse<PaginatedResponse<Agent>>>
@GET("agents/{id}")
suspend fun getAgent(
@Path("id") agentId: String
): Response<ApiResponse<Agent>>
@GET("agents/{id}/metrics")
suspend fun getAgentMetrics(
@Path("id") agentId: String,
@Query("from") from: Long? = null,
@Query("to") to: Long? = null
): Response<ApiResponse<Any>> // Kann später spezifiziert werden
// Alerts
@GET("alerts")
suspend fun getAlerts(
@Query("page") page: Int = 1,
@Query("pageSize") pageSize: Int = 50,
@Query("severity") severity: String? = null,
@Query("acknowledged") acknowledged: Boolean? = null
): Response<ApiResponse<PaginatedResponse<Alert>>>
@GET("alerts/{id}")
suspend fun getAlert(
@Path("id") alertId: String
): Response<ApiResponse<Alert>>
@POST("alerts/{id}/acknowledge")
suspend fun acknowledgeAlert(
@Path("id") alertId: String
): Response<ApiResponse<Alert>>
// Health Check
@GET("health")
suspend fun healthCheck(): Response<ApiResponse<Any>>
}

View File

@@ -0,0 +1,97 @@
package com.watcher.mobile.network
import com.google.gson.GsonBuilder
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
/**
* Retrofit Client Setup
*/
object RetrofitClient {
private var apiService: ApiService? = null
private var baseUrl: String = ""
private var apiKey: String? = null
/**
* Initialisiert den Retrofit Client mit Base URL und API Key
*/
fun initialize(baseUrl: String, apiKey: String? = null) {
this.baseUrl = baseUrl.let {
if (it.endsWith("/")) it else "$it/"
}
this.apiKey = apiKey
apiService = null // Reset service to force recreation
}
/**
* Gibt die API Service Instanz zurück
*/
fun getApiService(): ApiService {
if (apiService == null) {
if (baseUrl.isEmpty()) {
throw IllegalStateException("RetrofitClient not initialized. Call initialize() first.")
}
apiService = createApiService()
}
return apiService!!
}
private fun createApiService(): ApiService {
val gson = GsonBuilder()
.setLenient()
.create()
val retrofit = Retrofit.Builder()
.baseUrl(baseUrl)
.client(createOkHttpClient())
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
return retrofit.create(ApiService::class.java)
}
private fun createOkHttpClient(): OkHttpClient {
val loggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
val authInterceptor = Interceptor { chain ->
val originalRequest = chain.request()
val requestBuilder = originalRequest.newBuilder()
.header("Accept", "application/json")
.header("Content-Type", "application/json")
// Füge API Key hinzu, falls vorhanden
apiKey?.let {
requestBuilder.header("Authorization", "Bearer $it")
// Oder als Custom Header, je nach API:
// requestBuilder.header("X-API-Key", it)
}
chain.proceed(requestBuilder.build())
}
return OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.addInterceptor(loggingInterceptor)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
}
/**
* Gibt die aktuelle Base URL zurück
*/
fun getBaseUrl(): String = baseUrl
/**
* Prüft ob der Client initialisiert ist
*/
fun isInitialized(): Boolean = baseUrl.isNotEmpty()
}

View File

@@ -0,0 +1,137 @@
package com.watcher.mobile.repository
import com.watcher.mobile.data.*
import com.watcher.mobile.network.RetrofitClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Repository für Monitoring Daten
* Bietet eine saubere API für den Zugriff auf Remote-Daten
*/
class MonitoringRepository {
private val apiService by lazy { RetrofitClient.getApiService() }
/**
* Result Wrapper für API Calls
*/
sealed class Result<out T> {
data class Success<T>(val data: T) : Result<T>()
data class Error(val message: String, val exception: Throwable? = null) : Result<Nothing>()
object Loading : Result<Nothing>()
}
// Dashboard
suspend fun getDashboard(): Result<DashboardSummary> = withContext(Dispatchers.IO) {
try {
val response = apiService.getDashboard()
if (response.isSuccessful) {
val body = response.body()
if (body?.success == true && body.data != null) {
Result.Success(body.data)
} else {
Result.Error(body?.error ?: "Unknown error")
}
} else {
Result.Error("HTTP ${response.code()}: ${response.message()}")
}
} catch (e: Exception) {
Result.Error("Network error: ${e.message}", e)
}
}
// Agents
suspend fun getAgents(page: Int = 1, pageSize: Int = 50): Result<PaginatedResponse<Agent>> =
withContext(Dispatchers.IO) {
try {
val response = apiService.getAgents(page, pageSize)
if (response.isSuccessful) {
val body = response.body()
if (body?.success == true && body.data != null) {
Result.Success(body.data)
} else {
Result.Error(body?.error ?: "Unknown error")
}
} else {
Result.Error("HTTP ${response.code()}: ${response.message()}")
}
} catch (e: Exception) {
Result.Error("Network error: ${e.message}", e)
}
}
suspend fun getAgent(agentId: String): Result<Agent> = withContext(Dispatchers.IO) {
try {
val response = apiService.getAgent(agentId)
if (response.isSuccessful) {
val body = response.body()
if (body?.success == true && body.data != null) {
Result.Success(body.data)
} else {
Result.Error(body?.error ?: "Unknown error")
}
} else {
Result.Error("HTTP ${response.code()}: ${response.message()}")
}
} catch (e: Exception) {
Result.Error("Network error: ${e.message}", e)
}
}
// Alerts
suspend fun getAlerts(
page: Int = 1,
pageSize: Int = 50,
severity: String? = null,
acknowledged: Boolean? = null
): Result<PaginatedResponse<Alert>> = withContext(Dispatchers.IO) {
try {
val response = apiService.getAlerts(page, pageSize, severity, acknowledged)
if (response.isSuccessful) {
val body = response.body()
if (body?.success == true && body.data != null) {
Result.Success(body.data)
} else {
Result.Error(body?.error ?: "Unknown error")
}
} else {
Result.Error("HTTP ${response.code()}: ${response.message()}")
}
} catch (e: Exception) {
Result.Error("Network error: ${e.message}", e)
}
}
suspend fun acknowledgeAlert(alertId: String): Result<Alert> = withContext(Dispatchers.IO) {
try {
val response = apiService.acknowledgeAlert(alertId)
if (response.isSuccessful) {
val body = response.body()
if (body?.success == true && body.data != null) {
Result.Success(body.data)
} else {
Result.Error(body?.error ?: "Unknown error")
}
} else {
Result.Error("HTTP ${response.code()}: ${response.message()}")
}
} catch (e: Exception) {
Result.Error("Network error: ${e.message}", e)
}
}
// Health Check
suspend fun healthCheck(): Result<Boolean> = withContext(Dispatchers.IO) {
try {
val response = apiService.healthCheck()
if (response.isSuccessful) {
Result.Success(true)
} else {
Result.Error("Health check failed")
}
} catch (e: Exception) {
Result.Error("Health check failed: ${e.message}", e)
}
}
}

View File

@@ -0,0 +1,226 @@
package com.watcher.mobile.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.watcher.mobile.R
import com.watcher.mobile.data.Agent
import com.watcher.mobile.data.AgentStatus
import com.watcher.mobile.viewmodel.AgentsUiState
import com.watcher.mobile.viewmodel.AgentsViewModel
import java.text.SimpleDateFormat
import java.util.*
@Composable
fun AgentsScreen(
viewModel: AgentsViewModel = viewModel()
) {
val uiState by viewModel.uiState.collectAsState()
Column(modifier = Modifier.fillMaxSize()) {
when (val state = uiState) {
is AgentsUiState.Loading -> {
LoadingView()
}
is AgentsUiState.Success -> {
AgentsContent(
agents = state.agents,
onRefresh = { viewModel.refresh() }
)
}
is AgentsUiState.Error -> {
ErrorView(
message = state.message,
onRetry = { viewModel.refresh() }
)
}
}
}
}
@Composable
fun AgentsContent(
agents: List<Agent>,
onRefresh: () -> Unit
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
Text(
text = stringResource(R.string.agents_title),
style = MaterialTheme.typography.headlineLarge
)
}
if (agents.isEmpty()) {
item {
EmptyAgentsView()
}
} else {
items(agents) { agent ->
AgentCard(agent = agent)
}
}
}
}
@Composable
fun AgentCard(agent: Agent) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Row(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
// Status Indicator
Box(
modifier = Modifier
.size(48.dp),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = when (agent.status) {
AgentStatus.ONLINE -> Icons.Default.CheckCircle
AgentStatus.OFFLINE -> Icons.Default.Cancel
AgentStatus.WARNING -> Icons.Default.Warning
AgentStatus.ERROR -> Icons.Default.Error
},
contentDescription = null,
tint = when (agent.status) {
AgentStatus.ONLINE -> Color(0xFF4CAF50)
AgentStatus.OFFLINE -> Color(0xFF9E9E9E)
AgentStatus.WARNING -> Color(0xFFFFC107)
AgentStatus.ERROR -> Color(0xFFD32F2F)
},
modifier = Modifier.size(32.dp)
)
}
Spacer(modifier = Modifier.width(16.dp))
// Agent Info
Column(modifier = Modifier.weight(1f)) {
Text(
text = agent.name,
style = MaterialTheme.typography.titleMedium
)
agent.hostname?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
agent.ipAddress?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(
text = stringResource(
R.string.agents_last_seen,
formatTimestamp(agent.lastSeen)
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Metrics if available
agent.metrics?.let { metrics ->
Spacer(modifier = Modifier.height(8.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
metrics.cpuUsage?.let {
MetricBadge("CPU", "${it.toInt()}%")
}
metrics.memoryUsage?.let {
MetricBadge("RAM", "${it.toInt()}%")
}
metrics.diskUsage?.let {
MetricBadge("Disk", "${it.toInt()}%")
}
}
}
}
}
}
}
@Composable
fun MetricBadge(label: String, value: String) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
shape = MaterialTheme.shapes.small
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
text = value,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
@Composable
fun EmptyAgentsView() {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = Icons.Default.Computer,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "No agents found",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
private fun formatTimestamp(timestamp: Long): String {
val sdf = SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.getDefault())
return sdf.format(Date(timestamp))
}

View File

@@ -0,0 +1,264 @@
package com.watcher.mobile.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.watcher.mobile.R
import com.watcher.mobile.data.Alert
import com.watcher.mobile.data.AlertSeverity
import com.watcher.mobile.viewmodel.DashboardUiState
import com.watcher.mobile.viewmodel.DashboardViewModel
import java.text.SimpleDateFormat
import java.util.*
@Composable
fun DashboardScreen(
viewModel: DashboardViewModel = viewModel()
) {
val uiState by viewModel.uiState.collectAsState()
Column(modifier = Modifier.fillMaxSize()) {
when (val state = uiState) {
is DashboardUiState.Loading -> {
LoadingView()
}
is DashboardUiState.Success -> {
DashboardContent(
state = state,
onRefresh = { viewModel.refresh() }
)
}
is DashboardUiState.Error -> {
ErrorView(
message = state.message,
onRetry = { viewModel.refresh() }
)
}
}
}
}
@Composable
fun DashboardContent(
state: DashboardUiState.Success,
onRefresh: () -> Unit
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Header
item {
Text(
text = stringResource(R.string.dashboard_title),
style = MaterialTheme.typography.headlineLarge
)
}
// Summary Cards
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
SummaryCard(
title = "Online",
value = state.data.onlineAgents.toString(),
icon = Icons.Default.CheckCircle,
color = Color(0xFF4CAF50),
modifier = Modifier.weight(1f)
)
SummaryCard(
title = "Offline",
value = state.data.offlineAgents.toString(),
icon = Icons.Default.Cancel,
color = Color(0xFFFF5252),
modifier = Modifier.weight(1f)
)
}
}
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
SummaryCard(
title = "Alerts",
value = state.data.activeAlerts.toString(),
icon = Icons.Default.Notifications,
color = Color(0xFFFFC107),
modifier = Modifier.weight(1f)
)
SummaryCard(
title = "Critical",
value = state.data.criticalAlerts.toString(),
icon = Icons.Default.Warning,
color = Color(0xFFFF5252),
modifier = Modifier.weight(1f)
)
}
}
// Recent Alerts
if (state.data.recentAlerts.isNotEmpty()) {
item {
Text(
text = stringResource(R.string.dashboard_recent_alerts),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(top = 8.dp)
)
}
items(state.data.recentAlerts) { alert ->
AlertItem(alert = alert)
}
}
}
}
@Composable
fun SummaryCard(
title: String,
value: String,
icon: androidx.compose.ui.graphics.vector.ImageVector,
color: Color,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = color,
modifier = Modifier.size(32.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = value,
style = MaterialTheme.typography.headlineMedium,
color = color
)
Text(
text = title,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
@Composable
fun AlertItem(alert: Alert) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = when (alert.severity) {
AlertSeverity.CRITICAL -> Color(0xFFFFEBEE)
AlertSeverity.WARNING -> Color(0xFFFFF8E1)
AlertSeverity.INFO -> Color(0xFFE3F2FD)
}
)
) {
Row(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = when (alert.severity) {
AlertSeverity.CRITICAL -> Icons.Default.Error
AlertSeverity.WARNING -> Icons.Default.Warning
AlertSeverity.INFO -> Icons.Default.Info
},
contentDescription = null,
tint = when (alert.severity) {
AlertSeverity.CRITICAL -> Color(0xFFD32F2F)
AlertSeverity.WARNING -> Color(0xFFFFA000)
AlertSeverity.INFO -> Color(0xFF1976D2)
},
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = alert.title,
style = MaterialTheme.typography.titleMedium
)
Text(
text = alert.message,
style = MaterialTheme.typography.bodyMedium
)
Text(
text = formatTimestamp(alert.timestamp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@Composable
fun LoadingView() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
@Composable
fun ErrorView(message: String, onRetry: () -> Unit) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Icon(
imageVector = Icons.Default.Error,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(48.dp)
)
Text(
text = message,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error
)
Button(onClick = onRetry) {
Text(stringResource(R.string.retry))
}
}
}
}
private fun formatTimestamp(timestamp: Long): String {
val sdf = SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.getDefault())
return sdf.format(Date(timestamp))
}

View File

@@ -0,0 +1,76 @@
package com.watcher.mobile.ui.theme
import android.app.Activity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
// Light Theme Colors
private val LightColorScheme = lightColorScheme(
primary = Color(0xFF1976D2),
onPrimary = Color.White,
primaryContainer = Color(0xFFBBDEFB),
onPrimaryContainer = Color(0xFF003C6E),
secondary = Color(0xFF4CAF50),
onSecondary = Color.White,
secondaryContainer = Color(0xFFC8E6C9),
onSecondaryContainer = Color(0xFF1B5E20),
error = Color(0xFFD32F2F),
onError = Color.White,
errorContainer = Color(0xFFFFCDD2),
onErrorContainer = Color(0xFF8B0000),
background = Color(0xFFFAFAFA),
onBackground = Color(0xFF1C1B1F),
surface = Color.White,
onSurface = Color(0xFF1C1B1F)
)
// Dark Theme Colors
private val DarkColorScheme = darkColorScheme(
primary = Color(0xFF64B5F6),
onPrimary = Color(0xFF003C6E),
primaryContainer = Color(0xFF1976D2),
onPrimaryContainer = Color(0xFFBBDEFB),
secondary = Color(0xFF81C784),
onSecondary = Color(0xFF1B5E20),
secondaryContainer = Color(0xFF388E3C),
onSecondaryContainer = Color(0xFFC8E6C9),
error = Color(0xFFEF5350),
onError = Color(0xFF8B0000),
errorContainer = Color(0xFFD32F2F),
onErrorContainer = Color(0xFFFFCDD2),
background = Color(0xFF1C1B1F),
onBackground = Color(0xFFE6E1E5),
surface = Color(0xFF2C2C2E),
onSurface = Color(0xFFE6E1E5)
)
@Composable
fun WatcherMobileTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primary.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

View File

@@ -0,0 +1,59 @@
package com.watcher.mobile.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography = Typography(
displayLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 57.sp,
lineHeight = 64.sp,
letterSpacing = 0.sp
),
headlineLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 32.sp,
lineHeight = 40.sp,
letterSpacing = 0.sp
),
headlineMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 28.sp,
lineHeight = 36.sp,
letterSpacing = 0.sp
),
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
),
bodyMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp
),
labelLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
)
)

View File

@@ -0,0 +1,91 @@
package com.watcher.mobile.utils
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
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.map
/**
* PreferencesManager für App-Einstellungen
* Verwendet DataStore für moderne, sichere Preferences
*/
class PreferencesManager(private val context: Context) {
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "watcher_preferences")
companion object {
private val API_BASE_URL = stringPreferencesKey("api_base_url")
private val API_KEY = stringPreferencesKey("api_key")
private val REFRESH_INTERVAL = intPreferencesKey("refresh_interval")
private val NOTIFICATIONS_ENABLED = booleanPreferencesKey("notifications_enabled")
private val FIRST_RUN = booleanPreferencesKey("first_run")
}
// API Base URL
val apiBaseUrl: Flow<String?> = context.dataStore.data.map { preferences ->
preferences[API_BASE_URL]
}
suspend fun setApiBaseUrl(url: String) {
context.dataStore.edit { preferences ->
preferences[API_BASE_URL] = url
}
}
// API Key
val apiKey: Flow<String?> = context.dataStore.data.map { preferences ->
preferences[API_KEY]
}
suspend fun setApiKey(key: String) {
context.dataStore.edit { preferences ->
preferences[API_KEY] = key
}
}
// Refresh Interval (in Sekunden)
val refreshInterval: Flow<Int> = context.dataStore.data.map { preferences ->
preferences[REFRESH_INTERVAL] ?: 30 // Default: 30 Sekunden
}
suspend fun setRefreshInterval(interval: Int) {
context.dataStore.edit { preferences ->
preferences[REFRESH_INTERVAL] = interval
}
}
// Notifications
val notificationsEnabled: Flow<Boolean> = context.dataStore.data.map { preferences ->
preferences[NOTIFICATIONS_ENABLED] ?: true // Default: enabled
}
suspend fun setNotificationsEnabled(enabled: Boolean) {
context.dataStore.edit { preferences ->
preferences[NOTIFICATIONS_ENABLED] = enabled
}
}
// First Run
val isFirstRun: Flow<Boolean> = context.dataStore.data.map { preferences ->
preferences[FIRST_RUN] ?: true
}
suspend fun setFirstRunComplete() {
context.dataStore.edit { preferences ->
preferences[FIRST_RUN] = false
}
}
// Clear all preferences
suspend fun clearAll() {
context.dataStore.edit { preferences ->
preferences.clear()
}
}
}

View File

@@ -0,0 +1,52 @@
package com.watcher.mobile.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.watcher.mobile.data.Agent
import com.watcher.mobile.repository.MonitoringRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* ViewModel für Agents Screen
*/
class AgentsViewModel(
private val repository: MonitoringRepository = MonitoringRepository()
) : ViewModel() {
private val _uiState = MutableStateFlow<AgentsUiState>(AgentsUiState.Loading)
val uiState: StateFlow<AgentsUiState> = _uiState.asStateFlow()
init {
loadAgents()
}
fun loadAgents(page: Int = 1) {
viewModelScope.launch {
_uiState.value = AgentsUiState.Loading
when (val result = repository.getAgents(page)) {
is MonitoringRepository.Result.Success -> {
_uiState.value = AgentsUiState.Success(result.data.items)
}
is MonitoringRepository.Result.Error -> {
_uiState.value = AgentsUiState.Error(result.message)
}
is MonitoringRepository.Result.Loading -> {
_uiState.value = AgentsUiState.Loading
}
}
}
}
fun refresh() {
loadAgents()
}
}
sealed class AgentsUiState {
object Loading : AgentsUiState()
data class Success(val agents: List<Agent>) : AgentsUiState()
data class Error(val message: String) : AgentsUiState()
}

View File

@@ -0,0 +1,52 @@
package com.watcher.mobile.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.watcher.mobile.data.DashboardSummary
import com.watcher.mobile.repository.MonitoringRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* ViewModel für Dashboard Screen
*/
class DashboardViewModel(
private val repository: MonitoringRepository = MonitoringRepository()
) : ViewModel() {
private val _uiState = MutableStateFlow<DashboardUiState>(DashboardUiState.Loading)
val uiState: StateFlow<DashboardUiState> = _uiState.asStateFlow()
init {
loadDashboard()
}
fun loadDashboard() {
viewModelScope.launch {
_uiState.value = DashboardUiState.Loading
when (val result = repository.getDashboard()) {
is MonitoringRepository.Result.Success -> {
_uiState.value = DashboardUiState.Success(result.data)
}
is MonitoringRepository.Result.Error -> {
_uiState.value = DashboardUiState.Error(result.message)
}
is MonitoringRepository.Result.Loading -> {
_uiState.value = DashboardUiState.Loading
}
}
}
}
fun refresh() {
loadDashboard()
}
}
sealed class DashboardUiState {
object Loading : DashboardUiState()
data class Success(val data: DashboardSummary) : DashboardUiState()
data class Error(val message: String) : DashboardUiState()
}

View File

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Watcher Mobile</string>
<!-- Navigation -->
<string name="nav_dashboard">Dashboard</string>
<string name="nav_agents">Agents</string>
<string name="nav_alerts">Alerts</string>
<string name="nav_settings">Settings</string>
<!-- Dashboard -->
<string name="dashboard_title">Monitoring Dashboard</string>
<string name="dashboard_system_status">System Status</string>
<string name="dashboard_active_agents">Active Agents</string>
<string name="dashboard_recent_alerts">Recent Alerts</string>
<!-- Agents -->
<string name="agents_title">Monitoring Agents</string>
<string name="agents_status_online">Online</string>
<string name="agents_status_offline">Offline</string>
<string name="agents_last_seen">Last seen: %1$s</string>
<!-- Alerts -->
<string name="alerts_title">System Alerts</string>
<string name="alerts_severity_critical">Critical</string>
<string name="alerts_severity_warning">Warning</string>
<string name="alerts_severity_info">Info</string>
<!-- Settings -->
<string name="settings_title">Settings</string>
<string name="settings_api_url">API URL</string>
<string name="settings_api_url_hint">https://your-api.com/api/</string>
<string name="settings_api_key">API Key</string>
<string name="settings_api_key_hint">Enter your API key</string>
<string name="settings_refresh_interval">Refresh Interval</string>
<string name="settings_notifications">Notifications</string>
<string name="settings_save">Save</string>
<!-- Common -->
<string name="error_network">Network error. Please check your connection.</string>
<string name="error_loading">Error loading data</string>
<string name="loading">Loading…</string>
<string name="refresh">Refresh</string>
<string name="retry">Retry</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.WatcherMobile" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<!-- Exclude DataStore preferences from backup -->
<exclude domain="sharedpref" path="datastore" />
</full-backup-content>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<exclude domain="sharedpref" path="datastore" />
</cloud-backup>
<device-transfer>
<exclude domain="sharedpref" path="datastore" />
</device-transfer>
</data-extraction-rules>