feat(android): add HomeScreen, SettingsScreen, LogsScreen with bottom nav

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sanju Sivalingam
2026-02-17 17:55:02 +05:30
parent 516b83bd0f
commit 4e9f0e14ae
5 changed files with 601 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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