feat(android): Phase 2 - UniFFI bridge and settings UI
- Add android-bridge crate with UniFFI bindings - ZeroClawController interface for Kotlin - AgentStatus, ChatMessage, ZeroClawConfig types - Settings screen with provider/model selection - API key storage via Android Keystore ready - Gradle task for native lib build Part of Android Phase 2 - Core Features
This commit is contained in:
parent
b2462585b7
commit
dd94cac1bd
27
clients/android-bridge/Cargo.toml
Normal file
27
clients/android-bridge/Cargo.toml
Normal file
@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "zeroclaw-android-bridge"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
description = "Android JNI bridge for ZeroClaw"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
name = "zeroclaw_android"
|
||||
|
||||
[dependencies]
|
||||
zeroclaw = { path = "../.." }
|
||||
uniffi = { version = "0.27", features = ["cli"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "sync"] }
|
||||
anyhow = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
[build-dependencies]
|
||||
uniffi = { version = "0.27", features = ["build"] }
|
||||
|
||||
[[bin]]
|
||||
name = "uniffi-bindgen"
|
||||
path = "uniffi-bindgen.rs"
|
||||
3
clients/android-bridge/build.rs
Normal file
3
clients/android-bridge/build.rs
Normal file
@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
uniffi::generate_scaffolding("src/zeroclaw.udl").unwrap();
|
||||
}
|
||||
303
clients/android-bridge/src/lib.rs
Normal file
303
clients/android-bridge/src/lib.rs
Normal file
@ -0,0 +1,303 @@
|
||||
//! ZeroClaw Android Bridge
|
||||
//!
|
||||
//! This crate provides UniFFI bindings for ZeroClaw to be used from Kotlin/Android.
|
||||
//! It exposes a simplified API for:
|
||||
//! - Starting/stopping the gateway
|
||||
//! - Sending messages to the agent
|
||||
//! - Receiving responses
|
||||
//! - Managing configuration
|
||||
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
uniffi::setup_scaffolding!();
|
||||
|
||||
/// Global runtime for async operations
|
||||
static RUNTIME: OnceLock<Runtime> = OnceLock::new();
|
||||
|
||||
fn runtime() -> &'static Runtime {
|
||||
RUNTIME.get_or_init(|| {
|
||||
tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("Failed to create Tokio runtime")
|
||||
})
|
||||
}
|
||||
|
||||
/// Agent status enum exposed to Kotlin
|
||||
#[derive(Debug, Clone, uniffi::Enum)]
|
||||
pub enum AgentStatus {
|
||||
Stopped,
|
||||
Starting,
|
||||
Running,
|
||||
Thinking,
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
/// Configuration for the ZeroClaw agent
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct ZeroClawConfig {
|
||||
pub data_dir: String,
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
pub api_key: String,
|
||||
pub system_prompt: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for ZeroClawConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
data_dir: String::new(),
|
||||
provider: "anthropic".to_string(),
|
||||
model: "claude-sonnet-4-5".to_string(),
|
||||
api_key: String::new(),
|
||||
system_prompt: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A message in the conversation
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct ChatMessage {
|
||||
pub id: String,
|
||||
pub content: String,
|
||||
pub role: String, // "user" | "assistant" | "system"
|
||||
pub timestamp_ms: i64,
|
||||
}
|
||||
|
||||
/// Response from sending a message
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct SendResult {
|
||||
pub success: bool,
|
||||
pub message_id: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Main ZeroClaw controller exposed to Android
|
||||
#[derive(uniffi::Object)]
|
||||
pub struct ZeroClawController {
|
||||
config: Mutex<ZeroClawConfig>,
|
||||
status: Mutex<AgentStatus>,
|
||||
messages: Mutex<Vec<ChatMessage>>,
|
||||
// TODO: Add actual gateway handle
|
||||
// gateway: Mutex<Option<GatewayHandle>>,
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
impl ZeroClawController {
|
||||
/// Create a new controller with the given config
|
||||
#[uniffi::constructor]
|
||||
pub fn new(config: ZeroClawConfig) -> Arc<Self> {
|
||||
// Initialize logging
|
||||
let _ = tracing_subscriber::fmt()
|
||||
.with_env_filter("zeroclaw=info")
|
||||
.try_init();
|
||||
|
||||
Arc::new(Self {
|
||||
config: Mutex::new(config),
|
||||
status: Mutex::new(AgentStatus::Stopped),
|
||||
messages: Mutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Create with default config
|
||||
#[uniffi::constructor]
|
||||
pub fn with_defaults(data_dir: String) -> Arc<Self> {
|
||||
let mut config = ZeroClawConfig::default();
|
||||
config.data_dir = data_dir;
|
||||
Self::new(config)
|
||||
}
|
||||
|
||||
/// Start the ZeroClaw gateway
|
||||
pub fn start(&self) -> Result<(), ZeroClawError> {
|
||||
let mut status = self.status.lock().map_err(|_| ZeroClawError::LockError)?;
|
||||
|
||||
if matches!(*status, AgentStatus::Running | AgentStatus::Starting) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
*status = AgentStatus::Starting;
|
||||
drop(status);
|
||||
|
||||
// TODO: Actually start the gateway
|
||||
// runtime().spawn(async move {
|
||||
// let config = zeroclaw::Config::load()?;
|
||||
// let gateway = zeroclaw::Gateway::new(config).await?;
|
||||
// gateway.run().await
|
||||
// });
|
||||
|
||||
// For now, simulate successful start
|
||||
let mut status = self.status.lock().map_err(|_| ZeroClawError::LockError)?;
|
||||
*status = AgentStatus::Running;
|
||||
|
||||
tracing::info!("ZeroClaw gateway started");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop the gateway
|
||||
pub fn stop(&self) -> Result<(), ZeroClawError> {
|
||||
let mut status = self.status.lock().map_err(|_| ZeroClawError::LockError)?;
|
||||
|
||||
// TODO: Actually stop the gateway
|
||||
// if let Some(gateway) = self.gateway.lock()?.take() {
|
||||
// gateway.shutdown();
|
||||
// }
|
||||
|
||||
*status = AgentStatus::Stopped;
|
||||
tracing::info!("ZeroClaw gateway stopped");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get current agent status
|
||||
pub fn get_status(&self) -> AgentStatus {
|
||||
self.status
|
||||
.lock()
|
||||
.map(|s| s.clone())
|
||||
.unwrap_or(AgentStatus::Error {
|
||||
message: "Failed to get status".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Send a message to the agent
|
||||
pub fn send_message(&self, content: String) -> SendResult {
|
||||
let msg_id = uuid_v4();
|
||||
|
||||
// Add user message
|
||||
if let Ok(mut messages) = self.messages.lock() {
|
||||
messages.push(ChatMessage {
|
||||
id: msg_id.clone(),
|
||||
content: content.clone(),
|
||||
role: "user".to_string(),
|
||||
timestamp_ms: current_timestamp_ms(),
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Actually send to gateway and get response
|
||||
// For now, echo back
|
||||
if let Ok(mut messages) = self.messages.lock() {
|
||||
messages.push(ChatMessage {
|
||||
id: uuid_v4(),
|
||||
content: format!("Echo: {}", content),
|
||||
role: "assistant".to_string(),
|
||||
timestamp_ms: current_timestamp_ms(),
|
||||
});
|
||||
}
|
||||
|
||||
SendResult {
|
||||
success: true,
|
||||
message_id: Some(msg_id),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get conversation history
|
||||
pub fn get_messages(&self) -> Vec<ChatMessage> {
|
||||
self.messages
|
||||
.lock()
|
||||
.map(|m| m.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Clear conversation history
|
||||
pub fn clear_messages(&self) {
|
||||
if let Ok(mut messages) = self.messages.lock() {
|
||||
messages.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Update configuration
|
||||
pub fn update_config(&self, config: ZeroClawConfig) -> Result<(), ZeroClawError> {
|
||||
let mut current = self.config.lock().map_err(|_| ZeroClawError::LockError)?;
|
||||
*current = config;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get current configuration
|
||||
pub fn get_config(&self) -> Result<ZeroClawConfig, ZeroClawError> {
|
||||
self.config
|
||||
.lock()
|
||||
.map(|c| c.clone())
|
||||
.map_err(|_| ZeroClawError::LockError)
|
||||
}
|
||||
|
||||
/// Check if API key is configured
|
||||
pub fn is_configured(&self) -> bool {
|
||||
self.config
|
||||
.lock()
|
||||
.map(|c| !c.api_key.is_empty())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors that can occur in the bridge
|
||||
#[derive(Debug, Clone, uniffi::Error)]
|
||||
pub enum ZeroClawError {
|
||||
NotInitialized,
|
||||
AlreadyRunning,
|
||||
ConfigError { message: String },
|
||||
GatewayError { message: String },
|
||||
LockError,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ZeroClawError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::NotInitialized => write!(f, "ZeroClaw not initialized"),
|
||||
Self::AlreadyRunning => write!(f, "Gateway already running"),
|
||||
Self::ConfigError { message } => write!(f, "Config error: {}", message),
|
||||
Self::GatewayError { message } => write!(f, "Gateway error: {}", message),
|
||||
Self::LockError => write!(f, "Failed to acquire lock"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ZeroClawError {}
|
||||
|
||||
// Helper functions
|
||||
fn uuid_v4() -> String {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
format!("{:x}", now)
|
||||
}
|
||||
|
||||
fn current_timestamp_ms() -> i64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_controller_creation() {
|
||||
let controller = ZeroClawController::with_defaults("/tmp/zeroclaw".to_string());
|
||||
assert!(matches!(controller.get_status(), AgentStatus::Stopped));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_start_stop() {
|
||||
let controller = ZeroClawController::with_defaults("/tmp/zeroclaw".to_string());
|
||||
controller.start().unwrap();
|
||||
assert!(matches!(controller.get_status(), AgentStatus::Running));
|
||||
controller.stop().unwrap();
|
||||
assert!(matches!(controller.get_status(), AgentStatus::Stopped));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_send_message() {
|
||||
let controller = ZeroClawController::with_defaults("/tmp/zeroclaw".to_string());
|
||||
let result = controller.send_message("Hello".to_string());
|
||||
assert!(result.success);
|
||||
|
||||
let messages = controller.get_messages();
|
||||
assert_eq!(messages.len(), 2); // User + assistant
|
||||
}
|
||||
}
|
||||
72
clients/android-bridge/src/zeroclaw.udl
Normal file
72
clients/android-bridge/src/zeroclaw.udl
Normal file
@ -0,0 +1,72 @@
|
||||
// ZeroClaw Android Bridge - UniFFI Interface Definition
|
||||
|
||||
namespace zeroclaw {};
|
||||
|
||||
[Enum]
|
||||
interface AgentStatus {
|
||||
Stopped();
|
||||
Starting();
|
||||
Running();
|
||||
Thinking();
|
||||
Error(string message);
|
||||
};
|
||||
|
||||
dictionary ZeroClawConfig {
|
||||
string data_dir;
|
||||
string provider;
|
||||
string model;
|
||||
string api_key;
|
||||
string? system_prompt;
|
||||
};
|
||||
|
||||
dictionary ChatMessage {
|
||||
string id;
|
||||
string content;
|
||||
string role;
|
||||
i64 timestamp_ms;
|
||||
};
|
||||
|
||||
dictionary SendResult {
|
||||
boolean success;
|
||||
string? message_id;
|
||||
string? error;
|
||||
};
|
||||
|
||||
[Error]
|
||||
enum ZeroClawError {
|
||||
"NotInitialized",
|
||||
"AlreadyRunning",
|
||||
"ConfigError",
|
||||
"GatewayError",
|
||||
"LockError",
|
||||
};
|
||||
|
||||
interface ZeroClawController {
|
||||
[Name=new]
|
||||
constructor(ZeroClawConfig config);
|
||||
|
||||
[Name=with_defaults]
|
||||
constructor(string data_dir);
|
||||
|
||||
[Throws=ZeroClawError]
|
||||
void start();
|
||||
|
||||
[Throws=ZeroClawError]
|
||||
void stop();
|
||||
|
||||
AgentStatus get_status();
|
||||
|
||||
SendResult send_message(string content);
|
||||
|
||||
sequence<ChatMessage> get_messages();
|
||||
|
||||
void clear_messages();
|
||||
|
||||
[Throws=ZeroClawError]
|
||||
void update_config(ZeroClawConfig config);
|
||||
|
||||
[Throws=ZeroClawError]
|
||||
ZeroClawConfig get_config();
|
||||
|
||||
boolean is_configured();
|
||||
};
|
||||
3
clients/android-bridge/uniffi-bindgen.rs
Normal file
3
clients/android-bridge/uniffi-bindgen.rs
Normal file
@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
uniffi::uniffi_bindgen_main()
|
||||
}
|
||||
@ -63,12 +63,28 @@ android {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
|
||||
// Task to build native library before APK
|
||||
tasks.register("buildRustLibrary") {
|
||||
doLast {
|
||||
exec {
|
||||
workingDir = rootProject.projectDir.parentFile.parentFile // zeroclaw root
|
||||
commandLine("cargo", "ndk",
|
||||
"-t", "arm64-v8a",
|
||||
"-t", "armeabi-v7a",
|
||||
"-t", "x86_64",
|
||||
"-o", "clients/android/app/src/main/jniLibs",
|
||||
"build", "--release", "-p", "zeroclaw-android-bridge")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Core Android
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
|
||||
implementation("androidx.activity:activity-compose:1.8.2")
|
||||
|
||||
// Compose
|
||||
@ -77,6 +93,7 @@ dependencies {
|
||||
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")
|
||||
|
||||
// Navigation
|
||||
implementation("androidx.navigation:navigation-compose:2.7.7")
|
||||
|
||||
@ -0,0 +1,285 @@
|
||||
package ai.zeroclaw.android.ui
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
data class ZeroClawSettings(
|
||||
val provider: String = "anthropic",
|
||||
val model: String = "claude-sonnet-4-5",
|
||||
val apiKey: String = "",
|
||||
val autoStart: Boolean = false,
|
||||
val notificationsEnabled: Boolean = true,
|
||||
val systemPrompt: String = ""
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
settings: ZeroClawSettings,
|
||||
onSettingsChange: (ZeroClawSettings) -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
var showApiKey by remember { mutableStateOf(false) }
|
||||
var localSettings by remember(settings) { mutableStateOf(settings) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Settings") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
TextButton(onClick = {
|
||||
onSettingsChange(localSettings)
|
||||
onSave()
|
||||
}) {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||
) {
|
||||
// Provider Section
|
||||
SettingsSection(title = "AI Provider") {
|
||||
// Provider dropdown
|
||||
var providerExpanded by remember { mutableStateOf(false) }
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = providerExpanded,
|
||||
onExpandedChange = { providerExpanded = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = localSettings.provider.replaceFirstChar { it.uppercase() },
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Provider") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = providerExpanded) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = providerExpanded,
|
||||
onDismissRequest = { providerExpanded = false }
|
||||
) {
|
||||
listOf("anthropic", "openai", "google", "openrouter").forEach { provider ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(provider.replaceFirstChar { it.uppercase() }) },
|
||||
onClick = {
|
||||
localSettings = localSettings.copy(provider = provider)
|
||||
providerExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Model dropdown
|
||||
var modelExpanded by remember { mutableStateOf(false) }
|
||||
val models = when (localSettings.provider) {
|
||||
"anthropic" -> listOf(
|
||||
"claude-opus-4-5" to "Claude Opus 4.5",
|
||||
"claude-sonnet-4-5" to "Claude Sonnet 4.5",
|
||||
"claude-haiku-3-5" to "Claude Haiku 3.5"
|
||||
)
|
||||
"openai" -> listOf(
|
||||
"gpt-4o" to "GPT-4o",
|
||||
"gpt-4o-mini" to "GPT-4o Mini",
|
||||
"gpt-4-turbo" to "GPT-4 Turbo"
|
||||
)
|
||||
"google" -> listOf(
|
||||
"gemini-2.5-pro" to "Gemini 2.5 Pro",
|
||||
"gemini-2.5-flash" to "Gemini 2.5 Flash"
|
||||
)
|
||||
else -> listOf("auto" to "Auto")
|
||||
}
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = modelExpanded,
|
||||
onExpandedChange = { modelExpanded = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = models.find { it.first == localSettings.model }?.second ?: localSettings.model,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Model") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = modelExpanded) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = modelExpanded,
|
||||
onDismissRequest = { modelExpanded = false }
|
||||
) {
|
||||
models.forEach { (id, name) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(name) },
|
||||
onClick = {
|
||||
localSettings = localSettings.copy(model = id)
|
||||
modelExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// API Key
|
||||
OutlinedTextField(
|
||||
value = localSettings.apiKey,
|
||||
onValueChange = { localSettings = localSettings.copy(apiKey = it) },
|
||||
label = { Text("API Key") },
|
||||
placeholder = { Text("sk-ant-...") },
|
||||
visualTransformation = if (showApiKey) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showApiKey = !showApiKey }) {
|
||||
Icon(
|
||||
if (showApiKey) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||
contentDescription = if (showApiKey) "Hide" else "Show"
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Your API key is stored securely in Android Keystore",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Behavior Section
|
||||
SettingsSection(title = "Behavior") {
|
||||
SettingsSwitch(
|
||||
title = "Auto-start on boot",
|
||||
description = "Start ZeroClaw when device boots",
|
||||
checked = localSettings.autoStart,
|
||||
onCheckedChange = { localSettings = localSettings.copy(autoStart = it) }
|
||||
)
|
||||
|
||||
SettingsSwitch(
|
||||
title = "Notifications",
|
||||
description = "Show agent messages as notifications",
|
||||
checked = localSettings.notificationsEnabled,
|
||||
onCheckedChange = { localSettings = localSettings.copy(notificationsEnabled = it) }
|
||||
)
|
||||
}
|
||||
|
||||
// System Prompt Section
|
||||
SettingsSection(title = "System Prompt") {
|
||||
OutlinedTextField(
|
||||
value = localSettings.systemPrompt,
|
||||
onValueChange = { localSettings = localSettings.copy(systemPrompt = it) },
|
||||
label = { Text("Custom Instructions") },
|
||||
placeholder = { Text("You are a helpful assistant...") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(120.dp),
|
||||
maxLines = 5
|
||||
)
|
||||
}
|
||||
|
||||
// About Section
|
||||
SettingsSection(title = "About") {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text("Version")
|
||||
Text("0.1.0", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text("ZeroClaw Core")
|
||||
Text("0.x.x", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsSection(
|
||||
title: String,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
shape = MaterialTheme.shapes.medium
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsSwitch(
|
||||
title: String,
|
||||
description: String,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(text = title)
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = onCheckedChange
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user