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