feat: replace mobile shell with native Android app
This commit is contained in:
@@ -89,13 +89,3 @@ lint/tmp/
|
|||||||
# Android Profiling
|
# Android Profiling
|
||||||
*.hprof
|
*.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
|
|
||||||
|
|||||||
@@ -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 {
|
android {
|
||||||
namespace "cloud.molberg.hermesmobile"
|
namespace 'cloud.molberg.hermesmobile'
|
||||||
compileSdk rootProject.ext.compileSdkVersion
|
compileSdk 35
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "cloud.molberg.hermesmobile"
|
applicationId 'cloud.molberg.hermesmobile'
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdk 23
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdk 35
|
||||||
versionCode 1
|
versionCode 2
|
||||||
versionName "1.0"
|
versionName '0.2.0-native'
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
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:!*~'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
minifyEnabled false
|
minifyEnabled false
|
||||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
repositories {
|
compileOptions {
|
||||||
flatDir{
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = '17'
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
implementation platform('androidx.compose:compose-bom:2025.01.01')
|
||||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
implementation 'androidx.activity:activity-compose:1.10.0'
|
||||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
implementation 'androidx.compose.animation:animation'
|
||||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
implementation 'androidx.compose.foundation:foundation'
|
||||||
implementation project(':capacitor-android')
|
implementation 'androidx.compose.material3:material3'
|
||||||
testImplementation "junit:junit:$junitVersion"
|
implementation 'androidx.compose.material:material-icons-extended'
|
||||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
implementation 'androidx.compose.ui:ui'
|
||||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||||
implementation project(':capacitor-cordova-android-plugins')
|
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'
|
debugImplementation 'androidx.compose.ui:ui-tooling'
|
||||||
|
testImplementation 'junit:junit:4.13.2'
|
||||||
try {
|
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
|
||||||
def servicesJSON = file('google-services.json')
|
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
-26
@@ -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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/AppTheme">
|
android:theme="@style/AppTheme">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
|
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:label="@string/title_activity_main"
|
android:exported="true"
|
||||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
android:screenOrientation="portrait">
|
||||||
android:launchMode="singleTask"
|
|
||||||
android:exported="true">
|
|
||||||
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
</activity>
|
</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>
|
</application>
|
||||||
|
|
||||||
<!-- Permissions -->
|
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
|
||||||
</manifest>
|
</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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
|
<style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar">
|
||||||
<!-- Base application theme. -->
|
<item name="android:fontFamily">sans</item>
|
||||||
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
<item name="android:windowLightStatusBar">true</item>
|
||||||
<!-- Customize your theme here. -->
|
<item name="android:windowLightNavigationBar">true</item>
|
||||||
<item name="colorPrimary">@color/colorPrimary</item>
|
<item name="android:colorAccent">@color/seed_green</item>
|
||||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
|
||||||
<item name="colorAccent">@color/colorAccent</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>
|
</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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +1,5 @@
|
|||||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
plugins {
|
||||||
|
id 'com.android.application' version '8.7.2' apply false
|
||||||
buildscript {
|
id 'org.jetbrains.kotlin.android' version '2.0.21' apply false
|
||||||
|
id 'org.jetbrains.kotlin.plugin.compose' version '2.0.21' apply false
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
|
||||||
@@ -1,5 +1,18 @@
|
|||||||
include ':app'
|
pluginManagement {
|
||||||
include ':capacitor-cordova-android-plugins'
|
repositories {
|
||||||
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
|
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'
|
||||||
|
|||||||
@@ -12,5 +12,4 @@ ext {
|
|||||||
junitVersion = '4.13.2'
|
junitVersion = '4.13.2'
|
||||||
androidxJunitVersion = '1.2.1'
|
androidxJunitVersion = '1.2.1'
|
||||||
androidxEspressoCoreVersion = '3.6.1'
|
androidxEspressoCoreVersion = '3.6.1'
|
||||||
cordovaAndroidVersion = '10.1.1'
|
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user