feat: replace mobile shell with native Android app

This commit is contained in:
Hermes Agent
2026-07-09 05:14:14 +00:00
parent d4e841a28c
commit 68fe7b81d2
15 changed files with 692 additions and 208 deletions
-10
View File
@@ -89,13 +89,3 @@ lint/tmp/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml
+44 -38
View File
@@ -1,54 +1,60 @@
apply plugin: 'com.android.application'
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.compose'
}
android {
namespace "cloud.molberg.hermesmobile"
compileSdk rootProject.ext.compileSdkVersion
namespace 'cloud.molberg.hermesmobile'
compileSdk 35
defaultConfig {
applicationId "cloud.molberg.hermesmobile"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
applicationId 'cloud.molberg.hermesmobile'
minSdk 23
targetSdk 35
versionCode 2
versionName '0.2.0-native'
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
buildFeatures {
compose true
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
implementation platform('androidx.compose:compose-bom:2025.01.01')
implementation 'androidx.activity:activity-compose:1.10.0'
implementation 'androidx.compose.animation:animation'
implementation 'androidx.compose.foundation:foundation'
implementation 'androidx.compose.material3:material3'
implementation 'androidx.compose.material:material-icons-extended'
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.ui:ui-tooling-preview'
implementation 'androidx.core:core-ktx:1.15.0'
implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.8.7'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0'
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
implementation 'org.json:json:20240303'
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
debugImplementation 'androidx.compose.ui:ui-tooling'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
}
@@ -1,19 +0,0 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-haptics')
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}
@@ -1,26 +0,0 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import android.content.Context;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() throws Exception {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.getcapacitor.app", appContext.getPackageName());
}
}
@@ -1,41 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:usesCleartextTraffic="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
android:exported="true"
android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
</manifest>
@@ -1,5 +0,0 @@
package cloud.molberg.hermesmobile;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}
@@ -0,0 +1,613 @@
package cloud.molberg.hermesmobile
import android.app.Application
import android.content.Context
import android.os.Bundle
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChatBubble
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Save
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material.icons.filled.WifiOff
import androidx.compose.material.icons.outlined.Article
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.outlined.Diamond
import androidx.compose.material.icons.outlined.LocalFireDepartment
import androidx.compose.material.icons.outlined.EmojiEvents
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
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.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import java.net.URLEncoder
import java.util.concurrent.TimeUnit
enum class Screen(val label: String, val icon: ImageVector, val title: String, val kicker: String) {
Quest("Quest", Icons.Filled.ChatBubble, "Quest", "Hermes training path"),
Files("Files", Icons.Filled.Folder, "Backpack", "Workspace loot"),
Terminal("Terminal", Icons.Filled.Terminal, "Power move", "Run a command"),
Status("Status", Icons.Outlined.EmojiEvents, "League", "Companion stats"),
Settings("Settings", Icons.Filled.Settings, "Nest", "Connection setup")
}
data class FileEntry(val name: String, val path: String, val type: String, val size: Long)
data class ChatMessage(val role: String, val content: String)
data class HealthState(
val reachable: Boolean = false,
val status: String = "Tap refresh to check companion status.",
val hermesCli: String = "Unknown / unavailable",
val workspace: String = "Unknown",
val uptime: String = "",
val mode: String = ""
)
class CompanionApi(private val context: Context) {
private val prefs = context.getSharedPreferences("companion", Context.MODE_PRIVATE)
private val http = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.build()
var baseUrl: String
get() = prefs.getString("baseUrl", "http://10.0.2.2:8787") ?: "http://10.0.2.2:8787"
set(value) { prefs.edit().putString("baseUrl", value.trim().ifBlank { "http://10.0.2.2:8787" }).apply() }
var accessKey: String
get() = prefs.getString("accessKey", "") ?: ""
set(value) { prefs.edit().putString("accessKey", value.trim()).apply() }
private fun endpoint(path: String): String = baseUrl.trimEnd('/') + path
private fun request(path: String, method: String = "GET", body: JSONObject? = null): JSONObject {
val mediaType = "application/json; charset=utf-8".toMediaType()
val builder = Request.Builder()
.url(endpoint(path))
.header("Authorization", "Bearer $accessKey")
.header("Accept", "application/json")
val request = when (method) {
"POST" -> builder.post((body ?: JSONObject()).toString().toRequestBody(mediaType)).build()
"PUT" -> builder.put((body ?: JSONObject()).toString().toRequestBody(mediaType)).build()
else -> builder.get().build()
}
http.newCall(request).execute().use { response ->
val text = response.body?.string().orEmpty()
if (!response.isSuccessful) throw IllegalStateException(text.ifBlank { "HTTP ${response.code}" })
return if (text.isBlank()) JSONObject() else JSONObject(text)
}
}
suspend fun health(): HealthState = withContext(Dispatchers.IO) {
val json = request("/api/health")
val checks = json.optJSONObject("checks")?.optJSONObject("hermes")
val config = json.optJSONObject("config")
HealthState(
reachable = true,
status = "Companion is reachable.",
hermesCli = if (checks?.optBoolean("cliAvailable") == true) "Found at ${checks.optString("cliPath")}" else "Unknown / unavailable",
workspace = config?.optString("workspaceRoot") ?: "Unknown",
uptime = "${json.optDouble("uptimeSeconds", 0.0).toInt()}s",
mode = json.optString("mode", "")
)
}
suspend fun validate(): String = withContext(Dispatchers.IO) {
request("/api/auth/validate").optString("message", "Connection works.")
}
suspend fun chat(prompt: String): String = withContext(Dispatchers.IO) {
request("/api/chat", "POST", JSONObject().put("prompt", prompt)).optString("reply", "")
}
suspend fun listFiles(path: String): Pair<String, List<FileEntry>> = withContext(Dispatchers.IO) {
val encoded = URLEncoder.encode(path, "UTF-8")
val json = request("/api/files?path=$encoded")
val entries = json.optJSONArray("entries") ?: JSONArray()
json.optString("path", path) to List(entries.length()) { i ->
val item = entries.getJSONObject(i)
FileEntry(
name = item.optString("name"),
path = item.optString("path"),
type = item.optString("type"),
size = item.optLong("size", 0)
)
}
}
suspend fun readFile(path: String): Pair<String, String> = withContext(Dispatchers.IO) {
val encoded = URLEncoder.encode(path, "UTF-8")
val json = request("/api/files/read?path=$encoded")
json.optString("path", path) to json.optString("content", "")
}
suspend fun writeFile(path: String, content: String): String = withContext(Dispatchers.IO) {
val json = request("/api/files/write", "PUT", JSONObject().put("path", path).put("content", content))
"Saved ${json.optLong("bytesWritten")} bytes"
}
suspend fun runCommand(command: String, cwd: String): String = withContext(Dispatchers.IO) {
val json = request("/api/terminal/run", "POST", JSONObject().put("command", command).put("cwd", cwd))
listOf(
"$ ${json.optString("command", command)}",
json.optString("stdout"),
json.optString("stderr").takeIf { it.isNotBlank() }?.let { "stderr:\n$it" }.orEmpty(),
"exitCode=${json.opt("exitCode")} timedOut=${json.optBoolean("timedOut")}"
).filter { it.isNotBlank() }.joinToString("\n")
}
}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent { HermesNativeApp() }
}
}
@Composable
fun HermesNativeApp() {
val context = LocalContext.current
val api = remember { CompanionApi(context.applicationContext) }
var screen by remember { mutableStateOf(Screen.Quest) }
var online by remember { mutableStateOf(false) }
LaunchedEffect(screen) {
runCatching { api.health() }.onSuccess { online = true }.onFailure { online = false }
}
MaterialTheme(colorScheme = MaterialTheme.colorScheme.copy(background = Cream, surface = Paper)) {
Scaffold(
modifier = Modifier.fillMaxSize(),
containerColor = Cream,
topBar = { NativeTopBar(screen, online) },
bottomBar = {
NativeBottomBar(
current = screen,
onSelect = {
if (it != screen) vibrate(context)
screen = it
}
)
}
) { padding ->
AnimatedContent(
targetState = screen,
transitionSpec = { slideFor(initialState.ordinal, targetState.ordinal) },
modifier = Modifier
.fillMaxSize()
.padding(padding)
.imePadding(),
label = "screen-slide"
) { active ->
when (active) {
Screen.Quest -> QuestScreen(api)
Screen.Files -> FilesScreen(api)
Screen.Terminal -> TerminalScreen(api)
Screen.Status -> StatusScreen(api)
Screen.Settings -> SettingsScreen(api)
}
}
}
}
}
fun slideFor(from: Int, to: Int): ContentTransform {
val direction = if (to > from) 1 else -1
return slideInHorizontally(animationSpec = tween(260)) { width -> direction * width } togetherWith
slideOutHorizontally(animationSpec = tween(260)) { width -> -direction * width }
}
fun vibrate(context: Context) {
val vibrator = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
val manager = context.getSystemService(Application.VIBRATOR_MANAGER_SERVICE) as VibratorManager
manager.defaultVibrator
} else {
@Suppress("DEPRECATION") context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
}
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK))
else @Suppress("DEPRECATION") vibrator.vibrate(20)
}
@Composable
fun NativeTopBar(screen: Screen, online: Boolean) {
Column(
Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
RaisedCircle(color = Green, shadow = GreenDark, content = "")
Column(Modifier.weight(1f)) {
TinyLabel(screen.kicker)
Text(screen.title, fontSize = 31.sp, fontWeight = FontWeight.Black, color = Ink, lineHeight = 31.sp)
}
StatChip(Icons.Outlined.LocalFireDepartment, "7", Orange)
StatChip(Icons.Outlined.Diamond, "550", Blue)
StatChip(Icons.Filled.Favorite, "3", Red)
}
RaisedCard(
color = if (online) PaleGreen else PaleOrange,
shadow = if (online) GreenShadow else OrangeShadow,
border = if (online) GreenBorder else OrangeBorder,
modifier = Modifier.fillMaxWidth()
) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(if (online) Icons.Filled.Wifi else Icons.Filled.WifiOff, contentDescription = null, tint = if (online) GreenDark else Orange)
Text(if (online) "Companion online — ready for quests" else "Companion offline — check Settings", color = if (online) GreenDark else Orange, fontWeight = FontWeight.Black)
}
}
}
}
@Composable
fun NativeBottomBar(current: Screen, onSelect: (Screen) -> Unit) {
Surface(shadowElevation = 14.dp, color = Paper, tonalElevation = 0.dp) {
NavigationBar(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.height(82.dp),
containerColor = Paper,
tonalElevation = 0.dp
) {
Screen.entries.forEach { item ->
NavigationBarItem(
selected = current == item,
onClick = { onSelect(item) },
icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label, fontWeight = FontWeight.Black, fontSize = 11.sp) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = GreenDark,
selectedTextColor = GreenDark,
indicatorColor = PaleGreen,
unselectedIconColor = Muted,
unselectedTextColor = Muted
)
)
}
}
}
}
@Composable
fun QuestScreen(api: CompanionApi) {
val scope = remember { CoroutineScope(Dispatchers.Main) }
var prompt by remember { mutableStateOf("") }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf("") }
val messages = remember { mutableStateListOf<ChatMessage>() }
ScreenList {
HeroCard(icon = Icons.Filled.ChatBubble, title = "Give Hermes one clean mission.", label = "Daily quest", text = "Short, direct prompts work best. The native app calls the local companion server.")
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
PathPill(Icons.Filled.CheckCircle, "Check", true)
PathPill(Icons.Outlined.Bolt, "Ask", true, current = true)
PathPill(Icons.Filled.ChatBubble, "Review", false)
}
if (messages.isEmpty()) EmptyLesson()
messages.forEach { Bubble(it) }
if (error.isNotBlank()) ErrorCard(error)
RaisedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
AppTextField(value = prompt, onValue = { prompt = it }, placeholder = "What should Hermes do?", minLines = 3)
PrimaryButton(text = if (busy) "Working…" else "Start quest", icon = Icons.Filled.PlayArrow, enabled = !busy && prompt.isNotBlank()) {
val p = prompt.trim()
prompt = ""
messages.add(ChatMessage("You", p))
busy = true
error = ""
scope.launch {
runCatching { api.chat(p) }
.onSuccess { messages.add(ChatMessage("Hermes", it)) }
.onFailure { error = it.message ?: "Chat failed" }
busy = false
}
}
}
}
}
}
@Composable
fun FilesScreen(api: CompanionApi) {
val scope = remember { CoroutineScope(Dispatchers.Main) }
var path by remember { mutableStateOf(".") }
var entries by remember { mutableStateOf<List<FileEntry>>(emptyList()) }
var activePath by remember { mutableStateOf("") }
var draft by remember { mutableStateOf("") }
var status by remember { mutableStateOf("") }
fun load(next: String = path) = scope.launch {
runCatching { api.listFiles(next) }
.onSuccess { (p, list) -> path = p; entries = list; activePath = ""; draft = ""; status = "" }
.onFailure { status = it.message ?: "Unable to list files" }
}
LaunchedEffect(Unit) { load(".") }
ScreenList {
CompactQuest(title = "Workspace files", label = "Backpack", text = "Tap folders to go deeper. Files open in a native editor.", icon = Icons.Filled.Folder) { load() }
RaisedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = { load(parentPath(path)) }, enabled = path != ".") { Text("Up", fontWeight = FontWeight.Black) }
Text(path, Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis, color = Muted, fontWeight = FontWeight.Black)
}
entries.forEach { entry ->
FileRow(entry) {
if (entry.type == "directory") load(entry.path) else scope.launch {
runCatching { api.readFile(entry.path) }
.onSuccess { (p, c) -> activePath = p; draft = c }
.onFailure { status = it.message ?: "Unable to read file" }
}
}
}
}
}
if (activePath.isNotBlank()) RaisedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
TinyLabel("Editing")
Text(activePath, color = Ink, fontWeight = FontWeight.Black, maxLines = 2, overflow = TextOverflow.Ellipsis)
AppTextField(draft, { draft = it }, "File content", minLines = 10)
PrimaryButton("Save", Icons.Filled.Save) {
scope.launch { runCatching { api.writeFile(activePath, draft) }.onSuccess { status = it }.onFailure { status = it.message ?: "Save failed" } }
}
}
}
if (status.isNotBlank()) ErrorCard(status)
}
}
@Composable
fun TerminalScreen(api: CompanionApi) {
val scope = remember { CoroutineScope(Dispatchers.Main) }
var cwd by remember { mutableStateOf(".") }
var command by remember { mutableStateOf("pwd && ls") }
var output by remember { mutableStateOf("Command output appears here.") }
var busy by remember { mutableStateOf(false) }
ScreenList {
HeroCard(icon = Icons.Filled.Terminal, title = "Run one safe command.", label = "Power move", text = "Commands execute through the companion server inside the configured workspace.", orbColor = Blue, orbShadow = BlueDark)
RaisedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
FieldLabel("Working directory")
AppTextField(cwd, { cwd = it }, ".")
FieldLabel("Command")
AppTextField(command, { command = it }, "pwd && ls", minLines = 4)
PrimaryButton(if (busy) "Running…" else "Run command", Icons.Filled.PlayArrow, enabled = !busy && command.isNotBlank()) {
busy = true
scope.launch {
runCatching { api.runCommand(command, cwd) }.onSuccess { output = it }.onFailure { output = it.message ?: "Command failed" }
busy = false
}
}
}
}
CodeCard(output)
}
}
@Composable
fun StatusScreen(api: CompanionApi) {
val scope = remember { CoroutineScope(Dispatchers.Main) }
var health by remember { mutableStateOf(HealthState()) }
fun refresh() = scope.launch { health = runCatching { api.health() }.getOrElse { HealthState(false, it.message ?: "Health check failed") } }
ScreenList {
CompactQuest(title = "Companion status", label = "League board", text = health.status, icon = Icons.Outlined.EmojiEvents, orbColor = Gold, orbShadow = GoldDark) { refresh() }
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Metric("Hermes", if (health.hermesCli.startsWith("Found")) "Ready" else "", Modifier.weight(1f))
Metric("Uptime", health.uptime, Modifier.weight(1f))
Metric("Mode", health.mode, Modifier.weight(1f))
}
RaisedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
FieldLabel("Companion URL")
Text(api.baseUrl, color = Ink, fontWeight = FontWeight.Black)
FieldLabel("Hermes CLI")
Text(health.hermesCli, color = Ink, fontWeight = FontWeight.Black)
FieldLabel("Workspace")
Text(health.workspace, color = Ink, fontWeight = FontWeight.Black)
}
}
}
}
@Composable
fun SettingsScreen(api: CompanionApi) {
val scope = remember { CoroutineScope(Dispatchers.Main) }
var baseUrl by remember { mutableStateOf(api.baseUrl) }
var accessKey by remember { mutableStateOf(api.accessKey) }
var status by remember { mutableStateOf("Paste the companion access key, then test the connection.") }
ScreenList {
CompactQuest(title = "Pair your companion", label = "Nest setup", text = status, icon = Icons.Filled.Settings, orbColor = Green, orbShadow = GreenDark) {}
RaisedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
FieldLabel("Companion server URL")
AppTextField(baseUrl, { baseUrl = it }, "http://10.0.2.2:8787", keyboardType = KeyboardType.Uri)
FieldLabel("Access key")
AppTextField(accessKey, { accessKey = it }, "hm_…", password = true)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
SecondaryButton("Save", Icons.Filled.Save, Modifier.weight(1f)) { api.baseUrl = baseUrl; api.accessKey = accessKey; status = "Settings saved." }
PrimaryButton("Test", Icons.Filled.Wifi, Modifier.weight(1f)) {
api.baseUrl = baseUrl; api.accessKey = accessKey; status = "Testing…"
scope.launch { status = runCatching { api.validate() }.getOrElse { it.message ?: "Connection failed" } }
}
}
}
}
}
}
fun parentPath(path: String): String = path.split('/').filter { it.isNotBlank() }.dropLast(1).joinToString("/").ifBlank { "." }
@Composable
fun ScreenList(content: @Composable ColumnScope.() -> Unit) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
content = { item { Column(verticalArrangement = Arrangement.spacedBy(14.dp), content = content) } }
)
}
@Composable
fun RaisedCard(modifier: Modifier = Modifier, color: Color = Paper, shadow: Color = NeutralShadow, border: Color = NeutralBorder, content: @Composable () -> Unit) {
Box(modifier.padding(bottom = 5.dp)) {
Box(Modifier.matchParentSize().padding(top = 5.dp).clip(RoundedCornerShape(22.dp)).background(shadow))
Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(22.dp), colors = CardDefaults.cardColors(containerColor = color), border = BorderStroke(2.dp, border), elevation = CardDefaults.cardElevation(0.dp)) { content() }
}
}
@Composable
fun HeroCard(icon: ImageVector, title: String, label: String, text: String, orbColor: Color = Green, orbShadow: Color = GreenDark) {
RaisedCard(Modifier.fillMaxWidth()) {
Row(Modifier.padding(16.dp), horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.Top) {
RaisedIcon(icon, orbColor, orbShadow)
Column(Modifier.weight(1f)) { TinyLabel(label); Text(title, color = Ink, fontWeight = FontWeight.Black, fontSize = 28.sp, lineHeight = 29.sp); Text(text, color = Muted, fontWeight = FontWeight.ExtraBold, lineHeight = 18.sp) }
}
}
}
@Composable
fun CompactQuest(title: String, label: String, text: String, icon: ImageVector, orbColor: Color = Green, orbShadow: Color = GreenDark, onRefresh: () -> Unit) {
RaisedCard(Modifier.fillMaxWidth()) {
Row(Modifier.padding(14.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
RaisedIcon(icon, orbColor, orbShadow, size = 58)
Column(Modifier.weight(1f)) { TinyLabel(label); Text(title, color = Ink, fontWeight = FontWeight.Black, fontSize = 24.sp); Text(text, color = Muted, fontWeight = FontWeight.ExtraBold, lineHeight = 17.sp) }
if (onRefresh !== {}) RoundButton(Icons.Filled.Refresh, onRefresh)
}
}
}
@Composable fun RaisedIcon(icon: ImageVector, color: Color, shadow: Color, size: Int = 66) { Box(Modifier.size(size.dp), contentAlignment = Alignment.Center) { Box(Modifier.matchParentSize().padding(top = 5.dp).clip(CircleShape).background(shadow)); Box(Modifier.matchParentSize().padding(bottom = 5.dp).clip(CircleShape).background(color), contentAlignment = Alignment.Center) { Icon(icon, null, tint = Color.White, modifier = Modifier.size((size / 2).dp)) } } }
@Composable fun RaisedCircle(color: Color, shadow: Color, content: String) { Box(Modifier.size(54.dp), contentAlignment = Alignment.Center) { Box(Modifier.matchParentSize().padding(top = 5.dp).clip(RoundedCornerShape(18.dp)).background(shadow)); Box(Modifier.matchParentSize().padding(bottom = 5.dp).clip(RoundedCornerShape(18.dp)).background(color), contentAlignment = Alignment.Center) { Text(content, fontSize = 24.sp) } } }
@Composable fun StatChip(icon: ImageVector, value: String, color: Color) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.clip(RoundedCornerShape(99.dp)).background(Paper).padding(horizontal = 7.dp, vertical = 3.dp)) { Icon(icon, null, tint = color, modifier = Modifier.size(15.dp)); Text(value, color = color, fontWeight = FontWeight.Black, fontSize = 12.sp) } }
@Composable fun TinyLabel(text: String) { Text(text.uppercase(), color = Muted, fontSize = 11.sp, fontWeight = FontWeight.Black, letterSpacing = 0.7.sp) }
@Composable fun FieldLabel(text: String) { Text(text.uppercase(), color = Muted, fontSize = 12.sp, fontWeight = FontWeight.Black) }
@Composable fun PathPill(icon: ImageVector, text: String, done: Boolean, current: Boolean = false) { Row(Modifier.clip(RoundedCornerShape(99.dp)).background(if (current) Green else if (done) PaleGreen else Surface).padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) { Icon(icon, null, tint = if (current) Color.White else GreenDark, modifier = Modifier.size(18.dp)); Text(text, color = if (current) Color.White else GreenDark, fontWeight = FontWeight.Black, fontSize = 13.sp) } }
@Composable fun EmptyLesson() { RaisedCard(Modifier.fillMaxWidth()) { Column(Modifier.padding(18.dp), horizontalAlignment = Alignment.CenterHorizontally) { Text("🪽", fontSize = 42.sp); Text("No mission yet", color = Ink, fontWeight = FontWeight.Black, fontSize = 20.sp); Text("Try: check PM2 status or summarize logs.", color = Muted, fontWeight = FontWeight.ExtraBold) } } }
@Composable fun Bubble(msg: ChatMessage) { RaisedCard(Modifier.fillMaxWidth().padding(start = if (msg.role == "You") 28.dp else 0.dp, end = if (msg.role == "You") 0.dp else 28.dp), color = if (msg.role == "You") PaleGreen else PaleGold) { Column(Modifier.padding(12.dp)) { TinyLabel(msg.role); Text(msg.content, color = Ink, fontWeight = FontWeight.ExtraBold, lineHeight = 19.sp) } } }
@Composable fun ErrorCard(text: String) { RaisedCard(Modifier.fillMaxWidth(), color = PaleRed, shadow = RedShadow, border = RedBorder) { Text(text, Modifier.padding(12.dp), color = Red, fontWeight = FontWeight.Black) } }
@Composable fun CodeCard(text: String) { RaisedCard(Modifier.fillMaxWidth()) { Text(text, Modifier.padding(14.dp), color = BlueDark, fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, fontSize = 13.sp, lineHeight = 18.sp) } }
@Composable fun FileRow(entry: FileEntry, onClick: () -> Unit) { RaisedCard(Modifier.fillMaxWidth()) { Row(Modifier.clickable(onClick = onClick).padding(10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { Box(Modifier.size(42.dp).clip(RoundedCornerShape(14.dp)).background(if (entry.type == "directory") PaleBlue else PaleGold), contentAlignment = Alignment.Center) { Icon(if (entry.type == "directory") Icons.Filled.Folder else Icons.Outlined.Article, null, tint = if (entry.type == "directory") Blue else GoldDark) }; Text(entry.name, Modifier.weight(1f), color = Ink, fontWeight = FontWeight.Black, maxLines = 1, overflow = TextOverflow.Ellipsis); Text(if (entry.type == "file") "${entry.size} B" else "dir", color = Muted, fontWeight = FontWeight.Black, fontSize = 12.sp) } } }
@Composable fun Metric(label: String, value: String, modifier: Modifier = Modifier) { RaisedCard(modifier) { Column(Modifier.padding(10.dp), horizontalAlignment = Alignment.CenterHorizontally) { Text(value, color = GreenDark, fontWeight = FontWeight.Black, fontSize = 18.sp, maxLines = 1, overflow = TextOverflow.Ellipsis); Text(label.uppercase(), color = Muted, fontWeight = FontWeight.Black, fontSize = 10.sp) } } }
@Composable fun AppTextField(value: String, onValue: (String) -> Unit, placeholder: String, minLines: Int = 1, keyboardType: KeyboardType = KeyboardType.Text, password: Boolean = false) { OutlinedTextField(value = value, onValueChange = onValue, modifier = Modifier.fillMaxWidth(), placeholder = { Text(placeholder, color = Muted) }, minLines = minLines, keyboardOptions = KeyboardOptions(keyboardType = keyboardType), visualTransformation = if (password) PasswordVisualTransformation() else androidx.compose.ui.text.input.VisualTransformation.None, shape = RoundedCornerShape(16.dp), colors = TextFieldDefaults.colors(focusedContainerColor = Surface, unfocusedContainerColor = Surface, focusedIndicatorColor = Green, unfocusedIndicatorColor = NeutralBorder, cursorColor = GreenDark)) }
@Composable fun PrimaryButton(text: String, icon: ImageVector, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) { Button(onClick = onClick, enabled = enabled, modifier = modifier.height(54.dp), shape = RoundedCornerShape(16.dp), colors = ButtonDefaults.buttonColors(containerColor = Green, contentColor = Color.White, disabledContainerColor = NeutralBorder), elevation = ButtonDefaults.buttonElevation(defaultElevation = 6.dp, pressedElevation = 1.dp)) { Text(text, fontWeight = FontWeight.Black); Spacer(Modifier.width(6.dp)); Icon(icon, null) } }
@Composable fun SecondaryButton(text: String, icon: ImageVector, modifier: Modifier = Modifier, onClick: () -> Unit) { Button(onClick = onClick, modifier = modifier.height(54.dp), shape = RoundedCornerShape(16.dp), colors = ButtonDefaults.buttonColors(containerColor = Surface, contentColor = Ink), elevation = ButtonDefaults.buttonElevation(defaultElevation = 4.dp, pressedElevation = 1.dp)) { Text(text, fontWeight = FontWeight.Black); Spacer(Modifier.width(6.dp)); Icon(icon, null) } }
@Composable fun RoundButton(icon: ImageVector, onClick: () -> Unit) { Button(onClick = onClick, modifier = Modifier.size(50.dp), shape = RoundedCornerShape(16.dp), contentPadding = androidx.compose.foundation.layout.PaddingValues(0.dp), colors = ButtonDefaults.buttonColors(containerColor = Blue, contentColor = Color.White), elevation = ButtonDefaults.buttonElevation(defaultElevation = 5.dp, pressedElevation = 1.dp)) { Icon(icon, null) } }
val Green = Color(0xFF58CC02)
val GreenDark = Color(0xFF46A302)
val GreenShadow = Color(0xFFB8E9A4)
val GreenBorder = Color(0xFF8DDB6C)
val PaleGreen = Color(0xFFE8F8DF)
val Blue = Color(0xFF1CB0F6)
val BlueDark = Color(0xFF1687C2)
val PaleBlue = Color(0xFFE2F3FF)
val Gold = Color(0xFFFFC800)
val GoldDark = Color(0xFFC99500)
val PaleGold = Color(0xFFFFF6D6)
val Orange = Color(0xFFFF9600)
val PaleOrange = Color(0xFFFFF0D8)
val OrangeBorder = Color(0xFFE7BE82)
val OrangeShadow = Color(0xFFE4C99D)
val Red = Color(0xFFFF4B4B)
val PaleRed = Color(0xFFFFE4E4)
val RedBorder = Color(0xFFFFB8B8)
val RedShadow = Color(0xFFE9B4B4)
val Cream = Color(0xFFF2F7E8)
val Paper = Color(0xFFFFFEF8)
val Surface = Color(0xFFF3F8ED)
val Ink = Color(0xFF243B2E)
val Muted = Color(0xFF6D7C72)
val NeutralBorder = Color(0xFFDCE8D5)
val NeutralShadow = Color(0xFFC8D8C0)
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<WebView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="seed_green">#58CC02</color>
</resources>
@@ -1,22 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
<style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:windowLightStatusBar">true</item>
<item name="android:windowLightNavigationBar">true</item>
<item name="android:colorAccent">@color/seed_green</item>
</style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
</style>
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
</style>
</resources>
</resources>
@@ -1,18 +0,0 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import org.junit.Test;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() throws Exception {
assertEquals(4, 2 + 2);
}
}
+4 -28
View File
@@ -1,29 +1,5 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.7.2'
classpath 'com.google.gms:google-services:4.4.2'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
apply from: "variables.gradle"
allprojects {
repositories {
google()
mavenCentral()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
plugins {
id 'com.android.application' version '8.7.2' apply false
id 'org.jetbrains.kotlin.android' version '2.0.21' apply false
id 'org.jetbrains.kotlin.plugin.compose' version '2.0.21' apply false
}
@@ -1,6 +0,0 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../../../node_modules/@capacitor/android/capacitor')
include ':capacitor-haptics'
project(':capacitor-haptics').projectDir = new File('../../../node_modules/@capacitor/haptics/android')
+17 -4
View File
@@ -1,5 +1,18 @@
include ':app'
include ':capacitor-cordova-android-plugins'
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
apply from: 'capacitor.settings.gradle'
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = 'Hermes Mobile'
include ':app'
-1
View File
@@ -12,5 +12,4 @@ ext {
junitVersion = '4.13.2'
androidxJunitVersion = '1.2.1'
androidxEspressoCoreVersion = '3.6.1'
cordovaAndroidVersion = '10.1.1'
}