From 4e9f0e14aea9ef87f67516b6cfcbc007df4342d9 Mon Sep 17 00:00:00 2001 From: Sanju Sivalingam Date: Tue, 17 Feb 2026 17:55:02 +0530 Subject: [PATCH] feat(android): add HomeScreen, SettingsScreen, LogsScreen with bottom nav Co-Authored-By: Claude Opus 4.6 --- .../java/com/thisux/droidclaw/MainActivity.kt | 100 +++++++++ .../thisux/droidclaw/ui/screens/HomeScreen.kt | 196 ++++++++++++++++++ .../thisux/droidclaw/ui/screens/LogsScreen.kt | 106 ++++++++++ .../droidclaw/ui/screens/SettingsScreen.kt | 174 ++++++++++++++++ .../droidclaw/util/BatteryOptimization.kt | 25 +++ 5 files changed, 601 insertions(+) create mode 100644 android/app/src/main/java/com/thisux/droidclaw/MainActivity.kt create mode 100644 android/app/src/main/java/com/thisux/droidclaw/ui/screens/HomeScreen.kt create mode 100644 android/app/src/main/java/com/thisux/droidclaw/ui/screens/LogsScreen.kt create mode 100644 android/app/src/main/java/com/thisux/droidclaw/ui/screens/SettingsScreen.kt create mode 100644 android/app/src/main/java/com/thisux/droidclaw/util/BatteryOptimization.kt diff --git a/android/app/src/main/java/com/thisux/droidclaw/MainActivity.kt b/android/app/src/main/java/com/thisux/droidclaw/MainActivity.kt new file mode 100644 index 0000000..e4e6147 --- /dev/null +++ b/android/app/src/main/java/com/thisux/droidclaw/MainActivity.kt @@ -0,0 +1,100 @@ +package com.thisux.droidclaw + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +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.thisux.droidclaw.ui.screens.HomeScreen +import com.thisux.droidclaw.ui.screens.LogsScreen +import com.thisux.droidclaw.ui.screens.SettingsScreen +import com.thisux.droidclaw.ui.theme.DroidClawTheme + +sealed class Screen(val route: String, val label: String) { + data object Home : Screen("home", "Home") + data object Settings : Screen("settings", "Settings") + data object Logs : Screen("logs", "Logs") +} + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + DroidClawTheme { + MainNavigation() + } + } + } +} + +@Composable +fun MainNavigation() { + val navController = rememberNavController() + val screens = listOf(Screen.Home, Screen.Settings, Screen.Logs) + + Scaffold( + modifier = Modifier.fillMaxSize(), + bottomBar = { + NavigationBar { + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = navBackStackEntry?.destination + + screens.forEach { screen -> + NavigationBarItem( + icon = { + Icon( + when (screen) { + is Screen.Home -> Icons.Filled.Home + is Screen.Settings -> Icons.Filled.Settings + is Screen.Logs -> Icons.Filled.History + }, + contentDescription = screen.label + ) + }, + label = { Text(screen.label) }, + selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true, + onClick = { + navController.navigate(screen.route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } + ) + } + } + } + ) { innerPadding -> + NavHost( + navController = navController, + startDestination = Screen.Home.route, + modifier = Modifier.padding(innerPadding) + ) { + composable(Screen.Home.route) { HomeScreen() } + composable(Screen.Settings.route) { SettingsScreen() } + composable(Screen.Logs.route) { LogsScreen() } + } + } +} diff --git a/android/app/src/main/java/com/thisux/droidclaw/ui/screens/HomeScreen.kt b/android/app/src/main/java/com/thisux/droidclaw/ui/screens/HomeScreen.kt new file mode 100644 index 0000000..de4ce07 --- /dev/null +++ b/android/app/src/main/java/com/thisux/droidclaw/ui/screens/HomeScreen.kt @@ -0,0 +1,196 @@ +package com.thisux.droidclaw.ui.screens + +import android.content.Intent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.thisux.droidclaw.connection.ConnectionService +import com.thisux.droidclaw.model.ConnectionState +import com.thisux.droidclaw.model.GoalStatus + +@Composable +fun HomeScreen() { + val context = LocalContext.current + val connectionState by ConnectionService.connectionState.collectAsState() + val goalStatus by ConnectionService.currentGoalStatus.collectAsState() + val steps by ConnectionService.currentSteps.collectAsState() + val currentGoal by ConnectionService.currentGoal.collectAsState() + val errorMessage by ConnectionService.errorMessage.collectAsState() + + var goalInput by remember { mutableStateOf("") } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + // Status Badge + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Box( + modifier = Modifier + .size(12.dp) + .clip(CircleShape) + .background( + when (connectionState) { + ConnectionState.Connected -> Color(0xFF4CAF50) + ConnectionState.Connecting -> Color(0xFFFFC107) + ConnectionState.Error -> Color(0xFFF44336) + ConnectionState.Disconnected -> Color.Gray + } + ) + ) + Text( + text = when (connectionState) { + ConnectionState.Connected -> "Connected to server" + ConnectionState.Connecting -> "Connecting..." + ConnectionState.Error -> errorMessage ?: "Connection error" + ConnectionState.Disconnected -> "Disconnected" + }, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 8.dp) + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Connect/Disconnect button + Button( + onClick = { + val intent = Intent(context, ConnectionService::class.java).apply { + action = if (connectionState == ConnectionState.Disconnected || connectionState == ConnectionState.Error) { + ConnectionService.ACTION_CONNECT + } else { + ConnectionService.ACTION_DISCONNECT + } + } + context.startForegroundService(intent) + }, + modifier = Modifier.fillMaxWidth() + ) { + Text( + when (connectionState) { + ConnectionState.Disconnected, ConnectionState.Error -> "Connect" + else -> "Disconnect" + } + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Goal Input + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = goalInput, + onValueChange = { goalInput = it }, + label = { Text("Enter a goal...") }, + modifier = Modifier.weight(1f), + enabled = connectionState == ConnectionState.Connected && goalStatus != GoalStatus.Running, + singleLine = true + ) + Button( + onClick = { + if (goalInput.isNotBlank()) { + val intent = Intent(context, ConnectionService::class.java).apply { + action = ConnectionService.ACTION_SEND_GOAL + putExtra(ConnectionService.EXTRA_GOAL, goalInput) + } + context.startService(intent) + goalInput = "" + } + }, + enabled = connectionState == ConnectionState.Connected + && goalStatus != GoalStatus.Running + && goalInput.isNotBlank() + ) { + Text("Run") + } + } + + if (currentGoal.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Goal: $currentGoal", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Step Log + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(steps) { step -> + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = "Step ${step.step}: ${step.action}", + style = MaterialTheme.typography.titleSmall + ) + if (step.reasoning.isNotEmpty()) { + Text( + text = step.reasoning, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + + // Goal Status + if (goalStatus == GoalStatus.Completed || goalStatus == GoalStatus.Failed) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = if (goalStatus == GoalStatus.Completed) { + "Goal completed (${steps.size} steps)" + } else { + "Goal failed" + }, + style = MaterialTheme.typography.titleMedium, + color = if (goalStatus == GoalStatus.Completed) { + Color(0xFF4CAF50) + } else { + MaterialTheme.colorScheme.error + } + ) + } + } +} diff --git a/android/app/src/main/java/com/thisux/droidclaw/ui/screens/LogsScreen.kt b/android/app/src/main/java/com/thisux/droidclaw/ui/screens/LogsScreen.kt new file mode 100644 index 0000000..cda0cc2 --- /dev/null +++ b/android/app/src/main/java/com/thisux/droidclaw/ui/screens/LogsScreen.kt @@ -0,0 +1,106 @@ +package com.thisux.droidclaw.ui.screens + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.thisux.droidclaw.connection.ConnectionService +import com.thisux.droidclaw.model.GoalStatus + +@Composable +fun LogsScreen() { + val steps by ConnectionService.currentSteps.collectAsState() + val goalStatus by ConnectionService.currentGoalStatus.collectAsState() + val currentGoal by ConnectionService.currentGoal.collectAsState() + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + Text("Logs", style = MaterialTheme.typography.headlineMedium) + + if (currentGoal.isNotEmpty()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = currentGoal, + style = MaterialTheme.typography.titleSmall + ) + Text( + text = when (goalStatus) { + GoalStatus.Running -> "Running" + GoalStatus.Completed -> "Completed" + GoalStatus.Failed -> "Failed" + GoalStatus.Idle -> "Idle" + }, + color = when (goalStatus) { + GoalStatus.Running -> Color(0xFFFFC107) + GoalStatus.Completed -> Color(0xFF4CAF50) + GoalStatus.Failed -> MaterialTheme.colorScheme.error + GoalStatus.Idle -> Color.Gray + } + ) + } + } + + if (steps.isEmpty()) { + Text( + text = "No steps recorded yet. Submit a goal to see agent activity here.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 16.dp) + ) + } else { + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(steps) { step -> + var expanded by remember { mutableStateOf(false) } + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = "Step ${step.step}: ${step.action}", + style = MaterialTheme.typography.titleSmall + ) + if (expanded && step.reasoning.isNotEmpty()) { + Text( + text = step.reasoning, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp) + ) + } + } + } + } + } + } + } +} diff --git a/android/app/src/main/java/com/thisux/droidclaw/ui/screens/SettingsScreen.kt b/android/app/src/main/java/com/thisux/droidclaw/ui/screens/SettingsScreen.kt new file mode 100644 index 0000000..3903343 --- /dev/null +++ b/android/app/src/main/java/com/thisux/droidclaw/ui/screens/SettingsScreen.kt @@ -0,0 +1,174 @@ +package com.thisux.droidclaw.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Error +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import com.thisux.droidclaw.DroidClawApp +import com.thisux.droidclaw.accessibility.DroidClawAccessibilityService +import com.thisux.droidclaw.capture.ScreenCaptureManager +import com.thisux.droidclaw.util.BatteryOptimization +import kotlinx.coroutines.launch + +@Composable +fun SettingsScreen() { + val context = LocalContext.current + val app = context.applicationContext as DroidClawApp + val scope = rememberCoroutineScope() + + val apiKey by app.settingsStore.apiKey.collectAsState(initial = "") + val serverUrl by app.settingsStore.serverUrl.collectAsState(initial = "wss://localhost:8080") + + var editingApiKey by remember(apiKey) { mutableStateOf(apiKey) } + var editingServerUrl by remember(serverUrl) { mutableStateOf(serverUrl) } + + val isAccessibilityEnabled by DroidClawAccessibilityService.isRunning.collectAsState() + val isCaptureAvailable by ScreenCaptureManager.isAvailable.collectAsState() + val isBatteryExempt = remember { BatteryOptimization.isIgnoringBatteryOptimizations(context) } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text("Settings", style = MaterialTheme.typography.headlineMedium) + + OutlinedTextField( + value = editingApiKey, + onValueChange = { editingApiKey = it }, + label = { Text("API Key") }, + modifier = Modifier.fillMaxWidth(), + visualTransformation = PasswordVisualTransformation(), + singleLine = true + ) + if (editingApiKey != apiKey) { + OutlinedButton( + onClick = { scope.launch { app.settingsStore.setApiKey(editingApiKey) } } + ) { + Text("Save API Key") + } + } + + OutlinedTextField( + value = editingServerUrl, + onValueChange = { editingServerUrl = it }, + label = { Text("Server URL") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + if (editingServerUrl != serverUrl) { + OutlinedButton( + onClick = { scope.launch { app.settingsStore.setServerUrl(editingServerUrl) } } + ) { + Text("Save Server URL") + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text("Setup Checklist", style = MaterialTheme.typography.titleMedium) + + ChecklistItem( + label = "API key configured", + isOk = apiKey.isNotBlank(), + actionLabel = null, + onAction = {} + ) + + ChecklistItem( + label = "Accessibility service", + isOk = isAccessibilityEnabled, + actionLabel = "Enable", + onAction = { BatteryOptimization.openAccessibilitySettings(context) } + ) + + ChecklistItem( + label = "Screen capture permission", + isOk = isCaptureAvailable, + actionLabel = null, + onAction = {} + ) + + ChecklistItem( + label = "Battery optimization disabled", + isOk = isBatteryExempt, + actionLabel = "Disable", + onAction = { BatteryOptimization.requestExemption(context) } + ) + } +} + +@Composable +private fun ChecklistItem( + label: String, + isOk: Boolean, + actionLabel: String?, + onAction: () -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = if (isOk) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f) + } + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = if (isOk) Icons.Filled.CheckCircle else Icons.Filled.Error, + contentDescription = if (isOk) "OK" else "Missing", + tint = if (isOk) Color(0xFF4CAF50) else MaterialTheme.colorScheme.error + ) + Text(label) + } + if (!isOk && actionLabel != null) { + OutlinedButton(onClick = onAction) { + Text(actionLabel) + } + } + } + } +} diff --git a/android/app/src/main/java/com/thisux/droidclaw/util/BatteryOptimization.kt b/android/app/src/main/java/com/thisux/droidclaw/util/BatteryOptimization.kt new file mode 100644 index 0000000..0df446f --- /dev/null +++ b/android/app/src/main/java/com/thisux/droidclaw/util/BatteryOptimization.kt @@ -0,0 +1,25 @@ +package com.thisux.droidclaw.util + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.PowerManager +import android.provider.Settings + +object BatteryOptimization { + fun isIgnoringBatteryOptimizations(context: Context): Boolean { + val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager + return pm.isIgnoringBatteryOptimizations(context.packageName) + } + + fun requestExemption(context: Context) { + val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { + data = Uri.parse("package:${context.packageName}") + } + context.startActivity(intent) + } + + fun openAccessibilitySettings(context: Context) { + context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) + } +}