feat: extract compose design system
This commit is contained in:
+6
-1
@@ -5,10 +5,15 @@ All notable Hermes Mobile source changes are recorded here. Entries are added du
|
|||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- Added a Compose design-system package with color, semantic status, spacing, typography, shape, elevation, and motion tokens.
|
||||||
|
- Added light and dark Hermes Compose themes backed by Material 3 color schemes.
|
||||||
|
- Added Compose previews for small phone, normal phone, tablet width, light theme, and dark theme component coverage.
|
||||||
- Added native Android chat, workspace, settings, local defaults, companion health, and file editing surfaces for the beta client.
|
- Added native Android chat, workspace, settings, local defaults, companion health, and file editing surfaces for the beta client.
|
||||||
- Added `PROJECT_PLAN.md` as the project-controlled milestone plan.
|
- Added `PROJECT_PLAN.md` as the project-controlled milestone plan.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
- Extracted shared Compose cards, rows, buttons, text fields, notices, message bubbles, list scaffolds, and code surfaces out of `MainActivity.kt`.
|
||||||
|
- Updated the native Appearance screen to describe system light/dark theming and design tokens.
|
||||||
- Updated companion `/api/chat` to call `hermes chat -Q --source tool -q <prompt>`, sanitize CLI banners/help/control output, truncate large mobile replies, and return structured failures.
|
- Updated companion `/api/chat` to call `hermes chat -Q --source tool -q <prompt>`, sanitize CLI banners/help/control output, truncate large mobile replies, and return structured failures.
|
||||||
- Reconciled `docs/ROADMAP.md` to the current native Android beta state.
|
- Reconciled `docs/ROADMAP.md` to the current native Android beta state.
|
||||||
- Ignored `.gradle-user/` and `.dev/` alongside existing generated build artifacts.
|
- Ignored `.gradle-user/` and `.dev/` alongside existing generated build artifacts.
|
||||||
@@ -18,13 +23,13 @@ All notable Hermes Mobile source changes are recorded here. Entries are added du
|
|||||||
|
|
||||||
### Verification
|
### Verification
|
||||||
- `git diff --check` — passed
|
- `git diff --check` — passed
|
||||||
|
- `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :app:assembleDebug` from `apps/mobile/android` — passed
|
||||||
- `npm run typecheck --workspace @hermes-mobile/companion` — passed
|
- `npm run typecheck --workspace @hermes-mobile/companion` — passed
|
||||||
- `npm run lint --workspace @hermes-mobile/companion` — passed
|
- `npm run lint --workspace @hermes-mobile/companion` — passed
|
||||||
- `npm run build --workspace @hermes-mobile/companion` — passed
|
- `npm run build --workspace @hermes-mobile/companion` — passed
|
||||||
- `npm run typecheck` — passed
|
- `npm run typecheck` — passed
|
||||||
- `npm run lint` — passed
|
- `npm run lint` — passed
|
||||||
- `npm run build` — passed
|
- `npm run build` — passed
|
||||||
- `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :app:assembleDebug` from `apps/mobile/android` — blocked before compilation: network socket creation is not permitted while downloading Gradle 8.11.1
|
|
||||||
|
|
||||||
## Release history
|
## Release history
|
||||||
|
|
||||||
|
|||||||
+7
-7
@@ -22,13 +22,6 @@
|
|||||||
|
|
||||||
## Now — finish the foundation already in progress
|
## Now — finish the foundation already in progress
|
||||||
|
|
||||||
- [ ] **F1 — Compose design system extraction**
|
|
||||||
- Extract tokens for color, spacing, typography, shape, elevation, motion, and semantic status colors.
|
|
||||||
- Implement light/dark themes with readable contrast; eliminate default-sample styling.
|
|
||||||
- Split oversized UI into clear design, feature, and reusable component packages.
|
|
||||||
- Add Compose previews: small phone, normal phone, tablet width, light, dark.
|
|
||||||
- Verification: Android compile/build plus visual preview review.
|
|
||||||
|
|
||||||
- [ ] **F2 — Polished fake-data chat proving ground**
|
- [ ] **F2 — Polished fake-data chat proving ground**
|
||||||
- Session rail/list, conversation screen, context controls, keyboard-safe composer, send/cancel/retry behavior.
|
- Session rail/list, conversation screen, context controls, keyboard-safe composer, send/cancel/retry behavior.
|
||||||
- Render assistant/user/system/error/streaming messages from UI models.
|
- Render assistant/user/system/error/streaming messages from UI models.
|
||||||
@@ -98,6 +91,13 @@ _Move finished items here with date, commit, and verification. Keep this section
|
|||||||
- Verification: `git diff --check` passed; `npm run typecheck --workspace @hermes-mobile/companion` passed; `npm run lint --workspace @hermes-mobile/companion` passed; `npm run build --workspace @hermes-mobile/companion` passed; `npm run typecheck` passed; `npm run lint` passed; `npm run build` passed.
|
- Verification: `git diff --check` passed; `npm run typecheck --workspace @hermes-mobile/companion` passed; `npm run lint --workspace @hermes-mobile/companion` passed; `npm run build --workspace @hermes-mobile/companion` passed; `npm run typecheck` passed; `npm run lint` passed; `npm run build` passed.
|
||||||
- Android debug build attempted with `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :app:assembleDebug` from `apps/mobile/android`; blocked before compilation because network socket creation is not permitted while downloading Gradle 8.11.1.
|
- Android debug build attempted with `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :app:assembleDebug` from `apps/mobile/android`; blocked before compilation because network socket creation is not permitted while downloading Gradle 8.11.1.
|
||||||
|
|
||||||
|
- [x] **F1 — Compose design system extraction** — 2026-07-24, commit `PENDING` (`feat: extract compose design system`).
|
||||||
|
- Extracted color, semantic status, spacing, typography, shape, elevation, and motion tokens.
|
||||||
|
- Added light and dark Hermes Compose themes backed by Material 3 color schemes.
|
||||||
|
- Split shared Compose cards, rows, controls, notices, message bubbles, list scaffolds, and code surfaces out of `MainActivity.kt`.
|
||||||
|
- Added Compose previews for small phone, normal phone, tablet width, light theme, and dark theme component coverage.
|
||||||
|
- Verification: `git diff --check` passed; `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :app:assembleDebug` from `apps/mobile/android` passed.
|
||||||
|
|
||||||
## Work-loop checklist
|
## Work-loop checklist
|
||||||
|
|
||||||
Before coding:
|
Before coding:
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package cloud.molberg.hermesmobile
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Folder
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import cloud.molberg.hermesmobile.design.CodeCard
|
||||||
|
import cloud.molberg.hermesmobile.design.HermesMobileTheme
|
||||||
|
import cloud.molberg.hermesmobile.design.HermesTheme
|
||||||
|
import cloud.molberg.hermesmobile.design.ListRow
|
||||||
|
import cloud.molberg.hermesmobile.design.MessageBubble
|
||||||
|
import cloud.molberg.hermesmobile.design.NoticeCard
|
||||||
|
import cloud.molberg.hermesmobile.design.PrimaryButton
|
||||||
|
import cloud.molberg.hermesmobile.design.ScreenList
|
||||||
|
import cloud.molberg.hermesmobile.design.SectionHeader
|
||||||
|
import cloud.molberg.hermesmobile.design.TypingIndicator
|
||||||
|
|
||||||
|
@Preview(name = "Small Phone Light", widthDp = 320, heightDp = 640, showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun SmallPhoneLightPreview() {
|
||||||
|
HermesPreviewSurface(darkTheme = false) {
|
||||||
|
SampleChatThread()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(name = "Normal Phone Dark", widthDp = 393, heightDp = 852, uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun NormalPhoneDarkPreview() {
|
||||||
|
HermesPreviewSurface(darkTheme = true) {
|
||||||
|
SampleHomeList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(name = "Tablet Width Light", widthDp = 840, heightDp = 900, showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun TabletWidthLightPreview() {
|
||||||
|
HermesPreviewSurface(darkTheme = false) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(HermesTheme.spacing.lg),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(HermesTheme.spacing.lg)
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(0.42f)) { SampleHomeList() }
|
||||||
|
Column(Modifier.weight(0.58f)) { SampleChatThread() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(name = "Light Theme Components", widthDp = 393, heightDp = 700, showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun LightThemeComponentsPreview() {
|
||||||
|
HermesPreviewSurface(darkTheme = false) {
|
||||||
|
SampleComponentStack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(name = "Dark Theme Components", widthDp = 393, heightDp = 700, uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun DarkThemeComponentsPreview() {
|
||||||
|
HermesPreviewSurface(darkTheme = true) {
|
||||||
|
SampleComponentStack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HermesPreviewSurface(darkTheme: Boolean, content: @Composable () -> Unit) {
|
||||||
|
HermesMobileTheme(darkTheme = darkTheme) {
|
||||||
|
Surface(Modifier.fillMaxSize(), color = HermesTheme.colors.background) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SampleHomeList() {
|
||||||
|
ScreenList {
|
||||||
|
PrimaryButton("New Chat", Icons.Filled.Add, Modifier.fillMaxWidth()) {}
|
||||||
|
SectionHeader("Conversations", "2 active threads")
|
||||||
|
ListRow(Icons.Filled.PlayArrow, "Release checklist", "Hermes is working...") {}
|
||||||
|
ListRow(Icons.Filled.Folder, "Workspace cleanup", "Updated docs and Android checks") {}
|
||||||
|
SectionHeader("Status", "Companion and local defaults")
|
||||||
|
NoticeCard("Companion is reachable.")
|
||||||
|
NoticeCard("Hermes CLI was not found on PATH.", danger = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SampleChatThread() {
|
||||||
|
ScreenList {
|
||||||
|
SectionHeader("Chat", "Native conversation surface")
|
||||||
|
MessageBubble("You", "Summarize the current Android milestone and keep the output concise.")
|
||||||
|
MessageBubble("Hermes", "F1 extracts reusable theme tokens, shared surfaces, compact rows, controls, and previewable chat components.")
|
||||||
|
MessageBubble("Error", "Companion returned HTTP 401.")
|
||||||
|
TypingIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SampleComponentStack() {
|
||||||
|
ScreenList {
|
||||||
|
SectionHeader("Command", "Run a shell command on the companion workspace.")
|
||||||
|
PrimaryButton("Run command", Icons.Filled.PlayArrow) {}
|
||||||
|
CodeCard("$ ./gradlew :app:assembleDebug\nBUILD SUCCESSFUL")
|
||||||
|
SectionHeader("Workspace", "Rows preserve contrast and tap targets.")
|
||||||
|
ListRow(Icons.Filled.Folder, "Files", "Browse, read, and edit workspace files") {}
|
||||||
|
NoticeCard("Settings saved locally.")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,16 +16,12 @@ import androidx.compose.animation.core.tween
|
|||||||
import androidx.compose.animation.slideInHorizontally
|
import androidx.compose.animation.slideInHorizontally
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
@@ -34,20 +30,15 @@ import androidx.compose.foundation.layout.navigationBarsPadding
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.ChatBubble
|
import androidx.compose.material.icons.filled.ChatBubble
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
|
||||||
import androidx.compose.material.icons.filled.ChevronRight
|
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Folder
|
import androidx.compose.material.icons.filled.Folder
|
||||||
import androidx.compose.material.icons.filled.Info
|
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Palette
|
import androidx.compose.material.icons.filled.Palette
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
@@ -58,16 +49,30 @@ import androidx.compose.material.icons.filled.Settings
|
|||||||
import androidx.compose.material.icons.filled.Terminal
|
import androidx.compose.material.icons.filled.Terminal
|
||||||
import androidx.compose.material.icons.filled.Tune
|
import androidx.compose.material.icons.filled.Tune
|
||||||
import androidx.compose.material.icons.filled.Wifi
|
import androidx.compose.material.icons.filled.Wifi
|
||||||
import androidx.compose.material.icons.outlined.Article
|
|
||||||
import androidx.compose.material.icons.outlined.Bolt
|
import androidx.compose.material.icons.outlined.Bolt
|
||||||
import androidx.compose.material.icons.outlined.Security
|
import androidx.compose.material.icons.outlined.Security
|
||||||
import androidx.compose.material3.Button
|
import cloud.molberg.hermesmobile.design.AppTextField
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import cloud.molberg.hermesmobile.design.CodeCard
|
||||||
import androidx.compose.material3.Card
|
import cloud.molberg.hermesmobile.design.DetailList
|
||||||
import androidx.compose.material3.CardDefaults
|
import cloud.molberg.hermesmobile.design.EmptyState
|
||||||
|
import cloud.molberg.hermesmobile.design.FieldLabel
|
||||||
|
import cloud.molberg.hermesmobile.design.FileRow
|
||||||
|
import cloud.molberg.hermesmobile.design.FlatCard
|
||||||
|
import cloud.molberg.hermesmobile.design.HermesMobileTheme
|
||||||
|
import cloud.molberg.hermesmobile.design.HermesTheme
|
||||||
|
import cloud.molberg.hermesmobile.design.ListRow
|
||||||
|
import cloud.molberg.hermesmobile.design.MessageBubble
|
||||||
|
import cloud.molberg.hermesmobile.design.MetricRow
|
||||||
|
import cloud.molberg.hermesmobile.design.ModeChip
|
||||||
|
import cloud.molberg.hermesmobile.design.NoticeCard
|
||||||
|
import cloud.molberg.hermesmobile.design.PrimaryButton
|
||||||
|
import cloud.molberg.hermesmobile.design.ScreenList
|
||||||
|
import cloud.molberg.hermesmobile.design.SecondaryButton
|
||||||
|
import cloud.molberg.hermesmobile.design.SectionHeader
|
||||||
|
import cloud.molberg.hermesmobile.design.TinyLabel
|
||||||
|
import cloud.molberg.hermesmobile.design.TypingIndicator
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.NavigationBar
|
import androidx.compose.material3.NavigationBar
|
||||||
import androidx.compose.material3.NavigationBarItem
|
import androidx.compose.material3.NavigationBarItem
|
||||||
import androidx.compose.material3.NavigationBarItemDefaults
|
import androidx.compose.material3.NavigationBarItemDefaults
|
||||||
@@ -78,8 +83,6 @@ import androidx.compose.material3.SnackbarHostState
|
|||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextFieldDefaults
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
import androidx.compose.material3.darkColorScheme
|
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -93,11 +96,8 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
|
||||||
import androidx.compose.ui.text.input.VisualTransformation
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
@@ -112,7 +112,6 @@ import okhttp3.RequestBody.Companion.toRequestBody
|
|||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
import java.util.Locale
|
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
enum class Screen(val label: String, val icon: ImageVector, val title: String) {
|
enum class Screen(val label: String, val icon: ImageVector, val title: String) {
|
||||||
@@ -340,19 +339,10 @@ fun HermesNativeApp() {
|
|||||||
runCatching { api.health() }.onSuccess { online = true }.onFailure { online = false }
|
runCatching { api.health() }.onSuccess { online = true }.onFailure { online = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
MaterialTheme(
|
HermesMobileTheme {
|
||||||
colorScheme = darkColorScheme(
|
|
||||||
background = AppBackground,
|
|
||||||
surface = RowSurface,
|
|
||||||
primary = Ink,
|
|
||||||
onPrimary = AppBackground,
|
|
||||||
onBackground = Ink,
|
|
||||||
onSurface = Ink
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
containerColor = AppBackground,
|
containerColor = HermesTheme.colors.background,
|
||||||
topBar = {
|
topBar = {
|
||||||
NativeTopBar(screen, online, onAction = {
|
NativeTopBar(screen, online, onAction = {
|
||||||
if (screen == Screen.Inbox) {
|
if (screen == Screen.Inbox) {
|
||||||
@@ -428,15 +418,16 @@ fun NativeTopBar(screen: Screen, online: Boolean, onAction: () -> Unit) {
|
|||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
Text(screen.title, fontSize = 24.sp, fontWeight = FontWeight.SemiBold, color = Ink, lineHeight = 28.sp)
|
Text(screen.title, color = HermesTheme.colors.text, style = HermesTheme.typography.screenTitle)
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
Box(Modifier.size(8.dp).clip(CircleShape).background(if (online) Good else Muted))
|
val statusColor = if (online) HermesTheme.status.success else HermesTheme.colors.textMuted
|
||||||
Text(if (online) "connected" else "offline", color = if (online) Good else Muted, fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
|
Box(Modifier.size(8.dp).clip(CircleShape).background(statusColor))
|
||||||
|
Text(if (online) "connected" else "offline", color = statusColor, style = HermesTheme.typography.caption, fontWeight = FontWeight.SemiBold)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (screen == Screen.Inbox) {
|
if (screen == Screen.Inbox) {
|
||||||
IconButton(onClick = onAction, modifier = Modifier.size(44.dp)) {
|
IconButton(onClick = onAction, modifier = Modifier.size(44.dp)) {
|
||||||
Icon(Icons.Filled.Add, contentDescription = "New chat", tint = Ink)
|
Icon(Icons.Filled.Add, contentDescription = "New chat", tint = HermesTheme.colors.text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -444,14 +435,14 @@ fun NativeTopBar(screen: Screen, online: Boolean, onAction: () -> Unit) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NativeBottomBar(current: Screen, onSelect: (Screen) -> Unit) {
|
fun NativeBottomBar(current: Screen, onSelect: (Screen) -> Unit) {
|
||||||
Surface(shadowElevation = 0.dp, color = AppBackground, tonalElevation = 0.dp) {
|
Surface(shadowElevation = HermesTheme.elevation.none, color = HermesTheme.colors.background, tonalElevation = HermesTheme.elevation.none) {
|
||||||
NavigationBar(
|
NavigationBar(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.navigationBarsPadding()
|
.navigationBarsPadding()
|
||||||
.height(76.dp),
|
.height(76.dp),
|
||||||
containerColor = AppBackground,
|
containerColor = HermesTheme.colors.background,
|
||||||
tonalElevation = 0.dp
|
tonalElevation = HermesTheme.elevation.none
|
||||||
) {
|
) {
|
||||||
Screen.entries.forEach { item ->
|
Screen.entries.forEach { item ->
|
||||||
NavigationBarItem(
|
NavigationBarItem(
|
||||||
@@ -460,11 +451,11 @@ fun NativeBottomBar(current: Screen, onSelect: (Screen) -> Unit) {
|
|||||||
icon = { Icon(item.icon, contentDescription = item.label) },
|
icon = { Icon(item.icon, contentDescription = item.label) },
|
||||||
label = { Text(item.label, fontWeight = FontWeight.SemiBold, fontSize = 11.sp) },
|
label = { Text(item.label, fontWeight = FontWeight.SemiBold, fontSize = 11.sp) },
|
||||||
colors = NavigationBarItemDefaults.colors(
|
colors = NavigationBarItemDefaults.colors(
|
||||||
selectedIconColor = Ink,
|
selectedIconColor = HermesTheme.colors.text,
|
||||||
selectedTextColor = Ink,
|
selectedTextColor = HermesTheme.colors.text,
|
||||||
indicatorColor = Color.Transparent,
|
indicatorColor = Color.Transparent,
|
||||||
unselectedIconColor = Muted,
|
unselectedIconColor = HermesTheme.colors.textMuted,
|
||||||
unselectedTextColor = Muted
|
unselectedTextColor = HermesTheme.colors.textMuted
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -572,11 +563,11 @@ fun ConversationThread(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = onBack, modifier = Modifier.size(42.dp)) {
|
IconButton(onClick = onBack, modifier = Modifier.size(42.dp)) {
|
||||||
Icon(Icons.Filled.ArrowBack, contentDescription = "Conversations", tint = Ink)
|
Icon(Icons.Filled.ArrowBack, contentDescription = "Conversations", tint = HermesTheme.colors.text)
|
||||||
}
|
}
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(conversation.title, color = Ink, fontSize = 18.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
Text(conversation.title, color = HermesTheme.colors.text, style = HermesTheme.typography.sectionTitle, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
Text(if (conversation.busy) "Hermes is working..." else conversation.subtitle, color = Muted, fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
Text(if (conversation.busy) "Hermes is working..." else conversation.subtitle, color = HermesTheme.colors.textMuted, style = HermesTheme.typography.label, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
@@ -588,7 +579,7 @@ fun ConversationThread(
|
|||||||
item { EmptyState() }
|
item { EmptyState() }
|
||||||
}
|
}
|
||||||
items(conversation.messages) { message ->
|
items(conversation.messages) { message ->
|
||||||
Bubble(message)
|
MessageBubble(message.role, cleanMessage(message.content))
|
||||||
}
|
}
|
||||||
if (conversation.busy) {
|
if (conversation.busy) {
|
||||||
item { TypingIndicator() }
|
item { TypingIndicator() }
|
||||||
@@ -612,7 +603,7 @@ fun InboxComposer(
|
|||||||
canSend: Boolean,
|
canSend: Boolean,
|
||||||
onSend: () -> Unit
|
onSend: () -> Unit
|
||||||
) {
|
) {
|
||||||
Surface(color = AppBackground, tonalElevation = 0.dp) {
|
Surface(color = HermesTheme.colors.background, tonalElevation = HermesTheme.elevation.none) {
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -625,16 +616,16 @@ fun InboxComposer(
|
|||||||
value = prompt,
|
value = prompt,
|
||||||
onValueChange = onPrompt,
|
onValueChange = onPrompt,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
placeholder = { Text("Message Hermes", color = Muted) },
|
placeholder = { Text("Message Hermes", color = HermesTheme.colors.textMuted) },
|
||||||
minLines = 1,
|
minLines = 1,
|
||||||
maxLines = 5,
|
maxLines = 5,
|
||||||
shape = RoundedCornerShape(24.dp),
|
shape = RoundedCornerShape(24.dp),
|
||||||
colors = TextFieldDefaults.colors(
|
colors = TextFieldDefaults.colors(
|
||||||
focusedContainerColor = Surface,
|
focusedContainerColor = HermesTheme.colors.surfaceInput,
|
||||||
unfocusedContainerColor = Surface,
|
unfocusedContainerColor = HermesTheme.colors.surfaceInput,
|
||||||
focusedIndicatorColor = NeutralBorder,
|
focusedIndicatorColor = HermesTheme.colors.border,
|
||||||
unfocusedIndicatorColor = NeutralBorder,
|
unfocusedIndicatorColor = HermesTheme.colors.border,
|
||||||
cursorColor = Ink
|
cursorColor = HermesTheme.colors.text
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -643,12 +634,12 @@ fun InboxComposer(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(52.dp)
|
.size(52.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(if (!busy && canSend) Ink else Surface)
|
.background(if (!busy && canSend) HermesTheme.colors.primary else HermesTheme.colors.surface)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.Send,
|
Icons.Filled.Send,
|
||||||
contentDescription = "Send",
|
contentDescription = "Send",
|
||||||
tint = if (!busy && canSend) AppBackground else Muted
|
tint = if (!busy && canSend) HermesTheme.colors.onPrimary else HermesTheme.colors.textMuted
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -756,12 +747,12 @@ fun FilesScreen(api: CompanionApi, notify: (String) -> Unit, onBack: () -> Unit)
|
|||||||
SecondaryButton("Up", Icons.Filled.ArrowBack, Modifier.weight(0.8f), enabled = path != ".") { load(parentPath(path)) }
|
SecondaryButton("Up", Icons.Filled.ArrowBack, Modifier.weight(0.8f), enabled = path != ".") { load(parentPath(path)) }
|
||||||
PrimaryButton(if (loading) "Loading..." else "Refresh", Icons.Filled.Refresh, Modifier.weight(1f), enabled = !loading) { load() }
|
PrimaryButton(if (loading) "Loading..." else "Refresh", Icons.Filled.Refresh, Modifier.weight(1f), enabled = !loading) { load() }
|
||||||
}
|
}
|
||||||
Text(path, maxLines = 1, overflow = TextOverflow.Ellipsis, color = Muted, fontWeight = FontWeight.SemiBold)
|
Text(path, maxLines = 1, overflow = TextOverflow.Ellipsis, color = HermesTheme.colors.textMuted, fontWeight = FontWeight.SemiBold)
|
||||||
if (status.isNotBlank()) NoticeCard(status, danger = status.contains("failed", true) || status.contains("unable", true))
|
if (status.isNotBlank()) NoticeCard(status, danger = status.contains("failed", true) || status.contains("unable", true))
|
||||||
if (activePath.isNotBlank()) FlatCard {
|
if (activePath.isNotBlank()) FlatCard {
|
||||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
TinyLabel("Editing")
|
TinyLabel("Editing")
|
||||||
Text(activePath, color = Ink, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
Text(activePath, color = HermesTheme.colors.text, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
AppTextField(draft, { draft = it }, "File content", minLines = 10)
|
AppTextField(draft, { draft = it }, "File content", minLines = 10)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
SecondaryButton("Close", Icons.Filled.Close, Modifier.weight(1f)) { activePath = ""; draft = "" }
|
SecondaryButton("Close", Icons.Filled.Close, Modifier.weight(1f)) { activePath = ""; draft = "" }
|
||||||
@@ -781,7 +772,7 @@ fun FilesScreen(api: CompanionApi, notify: (String) -> Unit, onBack: () -> Unit)
|
|||||||
}
|
}
|
||||||
SectionHeader("Directory", if (loading) "Loading entries..." else "${entries.size} item${if (entries.size == 1) "" else "s"}")
|
SectionHeader("Directory", if (loading) "Loading entries..." else "${entries.size} item${if (entries.size == 1) "" else "s"}")
|
||||||
entries.forEach { entry ->
|
entries.forEach { entry ->
|
||||||
FileRow(entry) {
|
FileRow(entry.name, entry.type, entry.size) {
|
||||||
if (entry.type == "directory") {
|
if (entry.type == "directory") {
|
||||||
load(entry.path)
|
load(entry.path)
|
||||||
} else {
|
} else {
|
||||||
@@ -865,9 +856,9 @@ fun AppearanceSettings(api: CompanionApi, notify: (String) -> Unit, onBack: () -
|
|||||||
var completionDetail by remember { mutableStateOf(api.appearance.completionDetail) }
|
var completionDetail by remember { mutableStateOf(api.appearance.completionDetail) }
|
||||||
|
|
||||||
DetailList("Appearance", onBack) {
|
DetailList("Appearance", onBack) {
|
||||||
MetricRow("Theme", "Dark")
|
MetricRow("Theme", "Follows system light/dark")
|
||||||
MetricRow("Background", "#212121")
|
MetricRow("Tokens", "Color, spacing, type, shape, elevation, motion")
|
||||||
MetricRow("Rows", "#303030")
|
MetricRow("Status colors", "Success, warning, danger, neutral")
|
||||||
FieldLabel("Row density")
|
FieldLabel("Row density")
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
ModeChip("Comfortable", density == "Comfortable", Modifier.weight(1f)) { density = "Comfortable" }
|
ModeChip("Comfortable", density == "Comfortable", Modifier.weight(1f)) { density = "Comfortable" }
|
||||||
@@ -882,7 +873,7 @@ fun AppearanceSettings(api: CompanionApi, notify: (String) -> Unit, onBack: () -
|
|||||||
api.appearance = AppearancePrefs(density, completionDetail)
|
api.appearance = AppearancePrefs(density, completionDetail)
|
||||||
notify("Appearance settings saved locally.")
|
notify("Appearance settings saved locally.")
|
||||||
}
|
}
|
||||||
NoticeCard("These appearance preferences are stored on this Android device. The current native pass keeps the dark palette fixed to match the reference app.")
|
NoticeCard("These appearance preferences are stored on this Android device. The native theme now follows system light and dark mode.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -949,280 +940,3 @@ fun parentPath(path: String): String = path.split('/').filter { it.isNotBlank()
|
|||||||
|
|
||||||
fun cleanMessage(text: String): String =
|
fun cleanMessage(text: String): String =
|
||||||
cleanServerText(text).take(12000).ifBlank { "Hermes finished without output." }
|
cleanServerText(text).take(12000).ifBlank { "Hermes finished without output." }
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ScreenList(content: @Composable ColumnScope.() -> Unit) {
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
|
||||||
content = { item { Column(verticalArrangement = Arrangement.spacedBy(14.dp), content = content) } }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun DetailList(title: String, onBack: () -> Unit, content: @Composable ColumnScope.() -> Unit) {
|
|
||||||
ScreenList {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
|
||||||
IconButton(onClick = onBack, modifier = Modifier.size(42.dp)) { Icon(Icons.Filled.ArrowBack, contentDescription = "Back", tint = Ink) }
|
|
||||||
Text(title, color = Ink, fontSize = 22.sp, fontWeight = FontWeight.SemiBold)
|
|
||||||
}
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SectionHeader(title: String, detail: String) {
|
|
||||||
Column(Modifier.padding(top = 8.dp, start = 2.dp, end = 2.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
|
||||||
Text(title, color = Ink, fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
|
|
||||||
Text(detail, color = Muted, fontSize = 13.sp, lineHeight = 18.sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ListRow(icon: ImageVector, title: String, subtitle: String, onClick: () -> Unit) {
|
|
||||||
FlatCard {
|
|
||||||
Row(Modifier.clickable(onClick = onClick).padding(horizontal = 14.dp, vertical = 13.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
||||||
Box(Modifier.size(32.dp).clip(RoundedCornerShape(8.dp)).background(Surface), contentAlignment = Alignment.Center) {
|
|
||||||
Icon(icon, null, tint = Ink, modifier = Modifier.size(19.dp))
|
|
||||||
}
|
|
||||||
Column(Modifier.weight(1f)) {
|
|
||||||
Text(title, color = Ink, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
|
||||||
Text(subtitle, color = Muted, fontWeight = FontWeight.Normal, fontSize = 13.sp, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
|
||||||
}
|
|
||||||
Icon(Icons.Filled.ChevronRight, null, tint = Muted, modifier = Modifier.size(20.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun FlatCard(modifier: Modifier = Modifier, color: Color = RowSurface, border: Color = Color.Transparent, content: @Composable () -> Unit) {
|
|
||||||
Card(
|
|
||||||
modifier = modifier.fillMaxWidth(),
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
colors = CardDefaults.cardColors(containerColor = color),
|
|
||||||
border = BorderStroke(0.dp, border),
|
|
||||||
elevation = CardDefaults.cardElevation(0.dp)
|
|
||||||
) { content() }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ModeChip(text: String, selected: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
|
||||||
Surface(
|
|
||||||
modifier = modifier.height(44.dp).clip(RoundedCornerShape(12.dp)).clickable(onClick = onClick),
|
|
||||||
color = if (selected) Ink else RowSurface,
|
|
||||||
contentColor = if (selected) AppBackground else Ink
|
|
||||||
) {
|
|
||||||
Box(contentAlignment = Alignment.Center) {
|
|
||||||
Text(text, fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun EmptyState() {
|
|
||||||
Column(
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 14.dp, vertical = 28.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Box(Modifier.size(48.dp).clip(CircleShape).background(Surface), contentAlignment = Alignment.Center) {
|
|
||||||
Icon(Icons.Filled.ChatBubble, null, tint = Ink)
|
|
||||||
}
|
|
||||||
Text("Message Hermes", color = Ink, fontWeight = FontWeight.SemiBold, fontSize = 20.sp)
|
|
||||||
Text("Ask a question or send a workspace task. Replies will appear here.", color = Muted, lineHeight = 19.sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun Bubble(msg: ChatMessage) {
|
|
||||||
val isUser = msg.role == "You"
|
|
||||||
val isError = msg.role == "Error"
|
|
||||||
Row(
|
|
||||||
Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start
|
|
||||||
) {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(0.86f),
|
|
||||||
shape = RoundedCornerShape(
|
|
||||||
topStart = 18.dp,
|
|
||||||
topEnd = 18.dp,
|
|
||||||
bottomStart = if (isUser) 18.dp else 4.dp,
|
|
||||||
bottomEnd = if (isUser) 4.dp else 18.dp
|
|
||||||
),
|
|
||||||
colors = CardDefaults.cardColors(containerColor = when {
|
|
||||||
isError -> DangerPanel
|
|
||||||
isUser -> UserBubble
|
|
||||||
else -> Surface
|
|
||||||
}),
|
|
||||||
elevation = CardDefaults.cardElevation(0.dp)
|
|
||||||
) {
|
|
||||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
||||||
TinyLabel(if (isError) "Could not send" else msg.role)
|
|
||||||
SelectionContainer {
|
|
||||||
Text(
|
|
||||||
cleanMessage(msg.content),
|
|
||||||
color = if (isError) Danger else Ink,
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
lineHeight = 19.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun TypingIndicator() {
|
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(0.68f),
|
|
||||||
shape = RoundedCornerShape(18.dp, 18.dp, 18.dp, 4.dp),
|
|
||||||
colors = CardDefaults.cardColors(containerColor = Surface),
|
|
||||||
elevation = CardDefaults.cardElevation(0.dp)
|
|
||||||
) {
|
|
||||||
Row(Modifier.padding(horizontal = 13.dp, vertical = 11.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
Box(Modifier.size(8.dp).clip(CircleShape).background(Muted))
|
|
||||||
Text("Hermes is working...", color = Muted, fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun NoticeCard(text: String, danger: Boolean = false) {
|
|
||||||
FlatCard(color = if (danger) DangerPanel else Surface) {
|
|
||||||
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
|
||||||
Icon(if (danger) Icons.Filled.Info else Icons.Filled.CheckCircle, null, tint = if (danger) Danger else Good, modifier = Modifier.size(18.dp))
|
|
||||||
Text(text, Modifier.weight(1f), color = if (danger) Danger else Ink, fontWeight = FontWeight.Normal, lineHeight = 18.sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun CodeCard(text: String) {
|
|
||||||
FlatCard {
|
|
||||||
Text(text, Modifier.padding(14.dp), color = CodeText, fontFamily = FontFamily.Monospace, fontSize = 13.sp, lineHeight = 18.sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun FileRow(entry: FileEntry, onClick: () -> Unit) {
|
|
||||||
FlatCard {
|
|
||||||
Row(Modifier.clickable(onClick = onClick).padding(10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
|
||||||
Box(Modifier.size(38.dp).clip(RoundedCornerShape(8.dp)).background(Surface), contentAlignment = Alignment.Center) {
|
|
||||||
Icon(if (entry.type == "directory") Icons.Filled.Folder else Icons.Outlined.Article, null, tint = Ink, modifier = Modifier.size(20.dp))
|
|
||||||
}
|
|
||||||
Text(entry.name, Modifier.weight(1f), color = Ink, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
|
||||||
Text(if (entry.type == "file") formatBytes(entry.size) else "dir", color = Muted, fontWeight = FontWeight.SemiBold, fontSize = 12.sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun formatBytes(size: Long): String {
|
|
||||||
if (size < 1024) return "$size B"
|
|
||||||
val kb = size / 1024.0
|
|
||||||
if (kb < 1024) return String.format(Locale.US, "%.1f KB", kb)
|
|
||||||
return String.format(Locale.US, "%.1f MB", kb / 1024.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MetricRow(label: String, value: String) {
|
|
||||||
FlatCard {
|
|
||||||
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
||||||
Text(label, color = Muted, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, modifier = Modifier.width(104.dp))
|
|
||||||
Text(value, color = Ink, fontWeight = FontWeight.Normal, fontSize = 14.sp, modifier = Modifier.weight(1f), maxLines = 4, overflow = TextOverflow.Ellipsis)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun TinyLabel(text: String) {
|
|
||||||
Text(text.uppercase(Locale.US), color = Muted, fontSize = 11.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.sp)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun FieldLabel(text: String) {
|
|
||||||
Text(text.uppercase(Locale.US), color = Muted, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.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 VisualTransformation.None,
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
colors = TextFieldDefaults.colors(
|
|
||||||
focusedContainerColor = Surface,
|
|
||||||
unfocusedContainerColor = Surface,
|
|
||||||
focusedIndicatorColor = Muted,
|
|
||||||
unfocusedIndicatorColor = NeutralBorder,
|
|
||||||
cursorColor = Ink
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun PrimaryButton(text: String, icon: ImageVector, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) {
|
|
||||||
Button(
|
|
||||||
onClick = onClick,
|
|
||||||
enabled = enabled,
|
|
||||||
modifier = modifier.height(52.dp),
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = Ink, contentColor = AppBackground, disabledContainerColor = Surface, disabledContentColor = Muted),
|
|
||||||
elevation = ButtonDefaults.buttonElevation(defaultElevation = 0.dp, pressedElevation = 0.dp)
|
|
||||||
) {
|
|
||||||
Text(text, fontWeight = FontWeight.SemiBold)
|
|
||||||
Spacer(Modifier.width(6.dp))
|
|
||||||
Icon(icon, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SecondaryButton(text: String, icon: ImageVector, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) {
|
|
||||||
Button(
|
|
||||||
onClick = onClick,
|
|
||||||
enabled = enabled,
|
|
||||||
modifier = modifier.height(52.dp),
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = Surface, contentColor = Ink, disabledContainerColor = Surface, disabledContentColor = Muted),
|
|
||||||
elevation = ButtonDefaults.buttonElevation(defaultElevation = 0.dp, pressedElevation = 0.dp)
|
|
||||||
) {
|
|
||||||
Text(text, fontWeight = FontWeight.SemiBold)
|
|
||||||
Spacer(Modifier.width(6.dp))
|
|
||||||
Icon(icon, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun colorForStatus(status: String): Color = when (status) {
|
|
||||||
"done" -> Good
|
|
||||||
"running" -> Muted
|
|
||||||
"error" -> Danger
|
|
||||||
else -> Muted
|
|
||||||
}
|
|
||||||
|
|
||||||
val AppBackground = Color(0xFF212121)
|
|
||||||
val RowSurface = Color(0xFF303030)
|
|
||||||
val Surface = Color(0xFF2B2B2B)
|
|
||||||
val UserBubble = Color(0xFF263833)
|
|
||||||
val Ink = Color(0xFFF4F4F5)
|
|
||||||
val Muted = Color(0xFF9CA3AF)
|
|
||||||
val NeutralBorder = Color(0xFF3A3A3A)
|
|
||||||
val Good = Color(0xFF68D391)
|
|
||||||
val Danger = Color(0xFFF87171)
|
|
||||||
val DangerPanel = Color(0xFF3A2424)
|
|
||||||
val CodeText = Color(0xFFC7D2FE)
|
|
||||||
|
|||||||
+388
@@ -0,0 +1,388 @@
|
|||||||
|
package cloud.molberg.hermesmobile.design
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
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.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
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.text.selection.SelectionContainer
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.ChatBubble
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.ChevronRight
|
||||||
|
import androidx.compose.material.icons.filled.Folder
|
||||||
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
import androidx.compose.material.icons.outlined.Article
|
||||||
|
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.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
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.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ScreenList(content: @Composable ColumnScope.() -> Unit) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(horizontal = HermesTheme.spacing.lg, vertical = HermesTheme.spacing.sm),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
content = { item { Column(verticalArrangement = Arrangement.spacedBy(14.dp), content = content) } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DetailList(title: String, onBack: () -> Unit, content: @Composable ColumnScope.() -> Unit) {
|
||||||
|
ScreenList {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(HermesTheme.spacing.sm), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
androidx.compose.material3.IconButton(onClick = onBack, modifier = Modifier.size(42.dp)) {
|
||||||
|
Icon(Icons.Filled.ArrowBack, contentDescription = "Back", tint = HermesTheme.colors.text)
|
||||||
|
}
|
||||||
|
Text(title, color = HermesTheme.colors.text, style = HermesTheme.typography.panelTitle)
|
||||||
|
}
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SectionHeader(title: String, detail: String) {
|
||||||
|
Column(Modifier.padding(top = HermesTheme.spacing.sm, start = HermesTheme.spacing.xxs, end = HermesTheme.spacing.xxs), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||||
|
Text(title, color = HermesTheme.colors.text, style = HermesTheme.typography.sectionTitle)
|
||||||
|
Text(detail, color = HermesTheme.colors.textMuted, style = HermesTheme.typography.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FlatCard(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
color: Color = HermesTheme.colors.surfaceRaised,
|
||||||
|
border: Color = Color.Transparent,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(HermesTheme.shapes.card),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = color),
|
||||||
|
border = BorderStroke(HermesTheme.elevation.none, border),
|
||||||
|
elevation = CardDefaults.cardElevation(HermesTheme.elevation.none)
|
||||||
|
) { content() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ListRow(icon: ImageVector, title: String, subtitle: String, onClick: () -> Unit) {
|
||||||
|
FlatCard {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 14.dp, vertical = 13.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(HermesTheme.spacing.md)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(32.dp)
|
||||||
|
.clip(RoundedCornerShape(HermesTheme.shapes.iconTile))
|
||||||
|
.background(HermesTheme.colors.surface),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(icon, null, tint = HermesTheme.colors.text, modifier = Modifier.size(19.dp))
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(title, color = HermesTheme.colors.text, style = HermesTheme.typography.rowTitle, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
Text(subtitle, color = HermesTheme.colors.textMuted, style = HermesTheme.typography.caption, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
Icon(Icons.Filled.ChevronRight, null, tint = HermesTheme.colors.textMuted, modifier = Modifier.size(20.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ModeChip(text: String, selected: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
modifier = modifier
|
||||||
|
.height(44.dp)
|
||||||
|
.clip(RoundedCornerShape(HermesTheme.shapes.chip))
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
color = if (selected) HermesTheme.colors.primary else HermesTheme.colors.surfaceRaised,
|
||||||
|
contentColor = if (selected) HermesTheme.colors.onPrimary else HermesTheme.colors.text
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Text(text, fontWeight = FontWeight.SemiBold, style = HermesTheme.typography.body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun EmptyState() {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 14.dp, vertical = 28.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(HermesTheme.spacing.sm),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(HermesTheme.colors.surface),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.ChatBubble, null, tint = HermesTheme.colors.text)
|
||||||
|
}
|
||||||
|
Text("Message Hermes", color = HermesTheme.colors.text, style = HermesTheme.typography.sectionTitle)
|
||||||
|
Text("Ask a question or send a workspace task. Replies will appear here.", color = HermesTheme.colors.textMuted, style = HermesTheme.typography.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MessageBubble(role: String, content: String) {
|
||||||
|
val isUser = role == "You"
|
||||||
|
val isError = role == "Error"
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(0.86f),
|
||||||
|
shape = RoundedCornerShape(
|
||||||
|
topStart = HermesTheme.shapes.bubble,
|
||||||
|
topEnd = HermesTheme.shapes.bubble,
|
||||||
|
bottomStart = if (isUser) HermesTheme.shapes.bubble else 4.dp,
|
||||||
|
bottomEnd = if (isUser) 4.dp else HermesTheme.shapes.bubble
|
||||||
|
),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = when {
|
||||||
|
isError -> HermesTheme.status.dangerPanel
|
||||||
|
isUser -> HermesTheme.colors.userBubble
|
||||||
|
else -> HermesTheme.colors.surface
|
||||||
|
}
|
||||||
|
),
|
||||||
|
elevation = CardDefaults.cardElevation(HermesTheme.elevation.none)
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(HermesTheme.spacing.md), verticalArrangement = Arrangement.spacedBy(HermesTheme.spacing.xs)) {
|
||||||
|
TinyLabel(if (isError) "Could not send" else role)
|
||||||
|
SelectionContainer {
|
||||||
|
Text(
|
||||||
|
content,
|
||||||
|
color = if (isError) HermesTheme.status.danger else HermesTheme.colors.text,
|
||||||
|
style = HermesTheme.typography.body
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TypingIndicator() {
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(0.68f),
|
||||||
|
shape = RoundedCornerShape(HermesTheme.shapes.bubble, HermesTheme.shapes.bubble, HermesTheme.shapes.bubble, 4.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = HermesTheme.colors.surface),
|
||||||
|
elevation = CardDefaults.cardElevation(HermesTheme.elevation.none)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.padding(horizontal = 13.dp, vertical = 11.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(HermesTheme.spacing.sm)
|
||||||
|
) {
|
||||||
|
Box(Modifier.size(8.dp).clip(CircleShape).background(HermesTheme.colors.textMuted))
|
||||||
|
Text("Hermes is working...", color = HermesTheme.colors.textMuted, style = HermesTheme.typography.caption, fontWeight = FontWeight.SemiBold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NoticeCard(text: String, danger: Boolean = false) {
|
||||||
|
FlatCard(color = if (danger) HermesTheme.status.dangerPanel else HermesTheme.colors.surface) {
|
||||||
|
Row(
|
||||||
|
Modifier.padding(HermesTheme.spacing.md),
|
||||||
|
verticalAlignment = Alignment.Top,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
if (danger) Icons.Filled.Info else Icons.Filled.CheckCircle,
|
||||||
|
null,
|
||||||
|
tint = if (danger) HermesTheme.status.danger else HermesTheme.status.success,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Text(text, Modifier.weight(1f), color = if (danger) HermesTheme.status.danger else HermesTheme.colors.text, style = HermesTheme.typography.body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CodeCard(text: String) {
|
||||||
|
FlatCard {
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
Modifier
|
||||||
|
.horizontalScroll(rememberScrollState())
|
||||||
|
.padding(14.dp),
|
||||||
|
color = HermesTheme.colors.codeText,
|
||||||
|
style = HermesTheme.typography.code
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FileRow(name: String, type: String, size: Long, onClick: () -> Unit) {
|
||||||
|
FlatCard {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(38.dp)
|
||||||
|
.clip(RoundedCornerShape(HermesTheme.shapes.iconTile))
|
||||||
|
.background(HermesTheme.colors.surface),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(if (type == "directory") Icons.Filled.Folder else Icons.Outlined.Article, null, tint = HermesTheme.colors.text, modifier = Modifier.size(20.dp))
|
||||||
|
}
|
||||||
|
Text(name, Modifier.weight(1f), color = HermesTheme.colors.text, style = HermesTheme.typography.rowTitle, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
Text(if (type == "file") formatBytes(size) else "dir", color = HermesTheme.colors.textMuted, style = HermesTheme.typography.label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MetricRow(label: String, value: String) {
|
||||||
|
FlatCard {
|
||||||
|
Row(
|
||||||
|
Modifier.padding(HermesTheme.spacing.md),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(HermesTheme.spacing.md)
|
||||||
|
) {
|
||||||
|
Text(label, color = HermesTheme.colors.textMuted, style = HermesTheme.typography.caption, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(104.dp))
|
||||||
|
Text(value, color = HermesTheme.colors.text, style = HermesTheme.typography.body, modifier = Modifier.weight(1f), maxLines = 4, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TinyLabel(text: String) {
|
||||||
|
Text(text.uppercase(Locale.US), color = HermesTheme.colors.textMuted, style = HermesTheme.typography.tinyLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FieldLabel(text: String) {
|
||||||
|
Text(text.uppercase(Locale.US), color = HermesTheme.colors.textMuted, style = HermesTheme.typography.label)
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 = HermesTheme.colors.textMuted) },
|
||||||
|
minLines = minLines,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
|
||||||
|
visualTransformation = if (password) PasswordVisualTransformation() else VisualTransformation.None,
|
||||||
|
shape = RoundedCornerShape(HermesTheme.shapes.input),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = HermesTheme.colors.surfaceInput,
|
||||||
|
unfocusedContainerColor = HermesTheme.colors.surfaceInput,
|
||||||
|
focusedIndicatorColor = HermesTheme.colors.textMuted,
|
||||||
|
unfocusedIndicatorColor = HermesTheme.colors.border,
|
||||||
|
cursorColor = HermesTheme.colors.text
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PrimaryButton(text: String, icon: ImageVector, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) {
|
||||||
|
Button(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
|
modifier = modifier.height(52.dp),
|
||||||
|
shape = RoundedCornerShape(HermesTheme.shapes.input),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = HermesTheme.colors.primary,
|
||||||
|
contentColor = HermesTheme.colors.onPrimary,
|
||||||
|
disabledContainerColor = HermesTheme.colors.surface,
|
||||||
|
disabledContentColor = HermesTheme.colors.textMuted
|
||||||
|
),
|
||||||
|
elevation = ButtonDefaults.buttonElevation(defaultElevation = HermesTheme.elevation.none, pressedElevation = HermesTheme.elevation.none)
|
||||||
|
) {
|
||||||
|
Text(text, fontWeight = FontWeight.SemiBold)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Icon(icon, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SecondaryButton(text: String, icon: ImageVector, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) {
|
||||||
|
Button(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
|
modifier = modifier.height(52.dp),
|
||||||
|
shape = RoundedCornerShape(HermesTheme.shapes.input),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = HermesTheme.colors.surface,
|
||||||
|
contentColor = HermesTheme.colors.text,
|
||||||
|
disabledContainerColor = HermesTheme.colors.surface,
|
||||||
|
disabledContentColor = HermesTheme.colors.textMuted
|
||||||
|
),
|
||||||
|
elevation = ButtonDefaults.buttonElevation(defaultElevation = HermesTheme.elevation.none, pressedElevation = HermesTheme.elevation.none)
|
||||||
|
) {
|
||||||
|
Text(text, fontWeight = FontWeight.SemiBold)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Icon(icon, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun formatBytes(size: Long): String {
|
||||||
|
if (size < 1024) return "$size B"
|
||||||
|
val kb = size / 1024.0
|
||||||
|
if (kb < 1024) return String.format(Locale.US, "%.1f KB", kb)
|
||||||
|
return String.format(Locale.US, "%.1f MB", kb / 1024.0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package cloud.molberg.hermesmobile.design
|
||||||
|
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HermesMobileTheme(
|
||||||
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
val colors = if (darkTheme) darkHermesColors else lightHermesColors
|
||||||
|
val status = if (darkTheme) darkHermesStatusColors else lightHermesStatusColors
|
||||||
|
val materialColors = if (darkTheme) {
|
||||||
|
darkColorScheme(
|
||||||
|
background = colors.background,
|
||||||
|
surface = colors.surface,
|
||||||
|
surfaceVariant = colors.surfaceRaised,
|
||||||
|
primary = colors.primary,
|
||||||
|
onPrimary = colors.onPrimary,
|
||||||
|
onBackground = colors.text,
|
||||||
|
onSurface = colors.text,
|
||||||
|
outline = colors.border,
|
||||||
|
error = status.danger,
|
||||||
|
onError = colors.background
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
lightColorScheme(
|
||||||
|
background = colors.background,
|
||||||
|
surface = colors.surface,
|
||||||
|
surfaceVariant = colors.surfaceRaised,
|
||||||
|
primary = colors.primary,
|
||||||
|
onPrimary = colors.onPrimary,
|
||||||
|
onBackground = colors.text,
|
||||||
|
onSurface = colors.text,
|
||||||
|
outline = colors.border,
|
||||||
|
error = status.danger,
|
||||||
|
onError = colors.onPrimary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalHermesColors provides colors,
|
||||||
|
LocalHermesStatusColors provides status,
|
||||||
|
LocalHermesSpacing provides HermesSpacing(),
|
||||||
|
LocalHermesShapes provides HermesShapes(),
|
||||||
|
LocalHermesElevation provides HermesElevation(),
|
||||||
|
LocalHermesMotion provides HermesMotion(),
|
||||||
|
LocalHermesTypography provides HermesTypeScale()
|
||||||
|
) {
|
||||||
|
MaterialTheme(colorScheme = materialColors, content = content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object HermesTheme {
|
||||||
|
val colors: HermesColors
|
||||||
|
@Composable get() = LocalHermesColors.current
|
||||||
|
val status: HermesStatusColors
|
||||||
|
@Composable get() = LocalHermesStatusColors.current
|
||||||
|
val spacing: HermesSpacing
|
||||||
|
@Composable get() = LocalHermesSpacing.current
|
||||||
|
val shapes: HermesShapes
|
||||||
|
@Composable get() = LocalHermesShapes.current
|
||||||
|
val elevation: HermesElevation
|
||||||
|
@Composable get() = LocalHermesElevation.current
|
||||||
|
val motion: HermesMotion
|
||||||
|
@Composable get() = LocalHermesMotion.current
|
||||||
|
val typography: HermesTypeScale
|
||||||
|
@Composable get() = LocalHermesTypography.current
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun statusColorFor(status: String) = when (status.lowercase()) {
|
||||||
|
"done", "success", "ready", "connected" -> HermesTheme.status.success
|
||||||
|
"running", "loading", "pending" -> HermesTheme.status.neutral
|
||||||
|
"error", "failed", "unavailable", "offline" -> HermesTheme.status.danger
|
||||||
|
else -> HermesTheme.status.neutral
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package cloud.molberg.hermesmobile.design
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class HermesColors(
|
||||||
|
val background: Color,
|
||||||
|
val surface: Color,
|
||||||
|
val surfaceRaised: Color,
|
||||||
|
val surfaceInput: Color,
|
||||||
|
val userBubble: Color,
|
||||||
|
val primary: Color,
|
||||||
|
val onPrimary: Color,
|
||||||
|
val text: Color,
|
||||||
|
val textMuted: Color,
|
||||||
|
val border: Color,
|
||||||
|
val codeText: Color
|
||||||
|
)
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class HermesStatusColors(
|
||||||
|
val success: Color,
|
||||||
|
val successPanel: Color,
|
||||||
|
val warning: Color,
|
||||||
|
val warningPanel: Color,
|
||||||
|
val danger: Color,
|
||||||
|
val dangerPanel: Color,
|
||||||
|
val neutral: Color,
|
||||||
|
val neutralPanel: Color
|
||||||
|
)
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class HermesSpacing(
|
||||||
|
val none: Dp = 0.dp,
|
||||||
|
val xxs: Dp = 2.dp,
|
||||||
|
val xs: Dp = 4.dp,
|
||||||
|
val sm: Dp = 8.dp,
|
||||||
|
val md: Dp = 12.dp,
|
||||||
|
val lg: Dp = 16.dp,
|
||||||
|
val xl: Dp = 24.dp,
|
||||||
|
val xxl: Dp = 32.dp
|
||||||
|
)
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class HermesShapes(
|
||||||
|
val chip: Dp = 12.dp,
|
||||||
|
val card: Dp = 8.dp,
|
||||||
|
val input: Dp = 12.dp,
|
||||||
|
val bubble: Dp = 18.dp,
|
||||||
|
val iconTile: Dp = 8.dp
|
||||||
|
)
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class HermesElevation(
|
||||||
|
val none: Dp = 0.dp,
|
||||||
|
val raised: Dp = 1.dp
|
||||||
|
)
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class HermesMotion(
|
||||||
|
val screenTransitionMs: Int = 260,
|
||||||
|
val quickMs: Int = 140
|
||||||
|
) {
|
||||||
|
fun <T> screenTween(): FiniteAnimationSpec<T> = tween(screenTransitionMs)
|
||||||
|
fun <T> quickTween(): FiniteAnimationSpec<T> = tween(quickMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class HermesTypeScale(
|
||||||
|
val screenTitle: TextStyle = TextStyle(fontSize = 24.sp, lineHeight = 28.sp, fontWeight = FontWeight.SemiBold),
|
||||||
|
val panelTitle: TextStyle = TextStyle(fontSize = 22.sp, lineHeight = 27.sp, fontWeight = FontWeight.SemiBold),
|
||||||
|
val sectionTitle: TextStyle = TextStyle(fontSize = 18.sp, lineHeight = 24.sp, fontWeight = FontWeight.SemiBold),
|
||||||
|
val rowTitle: TextStyle = TextStyle(fontSize = 16.sp, lineHeight = 22.sp, fontWeight = FontWeight.SemiBold),
|
||||||
|
val body: TextStyle = TextStyle(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.Normal),
|
||||||
|
val bodyStrong: TextStyle = TextStyle(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.SemiBold),
|
||||||
|
val caption: TextStyle = TextStyle(fontSize = 13.sp, lineHeight = 18.sp, fontWeight = FontWeight.Normal),
|
||||||
|
val label: TextStyle = TextStyle(fontSize = 12.sp, lineHeight = 16.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.sp),
|
||||||
|
val tinyLabel: TextStyle = TextStyle(fontSize = 11.sp, lineHeight = 14.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.sp),
|
||||||
|
val code: TextStyle = TextStyle(fontSize = 13.sp, lineHeight = 18.sp, fontFamily = FontFamily.Monospace)
|
||||||
|
)
|
||||||
|
|
||||||
|
val LocalHermesColors = staticCompositionLocalOf { darkHermesColors }
|
||||||
|
val LocalHermesStatusColors = staticCompositionLocalOf { darkHermesStatusColors }
|
||||||
|
val LocalHermesSpacing = staticCompositionLocalOf { HermesSpacing() }
|
||||||
|
val LocalHermesShapes = staticCompositionLocalOf { HermesShapes() }
|
||||||
|
val LocalHermesElevation = staticCompositionLocalOf { HermesElevation() }
|
||||||
|
val LocalHermesMotion = staticCompositionLocalOf { HermesMotion() }
|
||||||
|
val LocalHermesTypography = staticCompositionLocalOf { HermesTypeScale() }
|
||||||
|
|
||||||
|
val lightHermesColors = HermesColors(
|
||||||
|
background = Color(0xFFFAFAF8),
|
||||||
|
surface = Color(0xFFFFFFFF),
|
||||||
|
surfaceRaised = Color(0xFFF0F2F4),
|
||||||
|
surfaceInput = Color(0xFFFFFFFF),
|
||||||
|
userBubble = Color(0xFFE5F4ED),
|
||||||
|
primary = Color(0xFF17201C),
|
||||||
|
onPrimary = Color(0xFFFFFFFF),
|
||||||
|
text = Color(0xFF15191D),
|
||||||
|
textMuted = Color(0xFF59636E),
|
||||||
|
border = Color(0xFFD7DCE0),
|
||||||
|
codeText = Color(0xFF303F8F)
|
||||||
|
)
|
||||||
|
|
||||||
|
val darkHermesColors = HermesColors(
|
||||||
|
background = Color(0xFF16181A),
|
||||||
|
surface = Color(0xFF24272A),
|
||||||
|
surfaceRaised = Color(0xFF2F3337),
|
||||||
|
surfaceInput = Color(0xFF202326),
|
||||||
|
userBubble = Color(0xFF203B32),
|
||||||
|
primary = Color(0xFFF5F7F8),
|
||||||
|
onPrimary = Color(0xFF16181A),
|
||||||
|
text = Color(0xFFF4F6F7),
|
||||||
|
textMuted = Color(0xFFADB7C1),
|
||||||
|
border = Color(0xFF3E454C),
|
||||||
|
codeText = Color(0xFFC8D3FF)
|
||||||
|
)
|
||||||
|
|
||||||
|
val lightHermesStatusColors = HermesStatusColors(
|
||||||
|
success = Color(0xFF146C43),
|
||||||
|
successPanel = Color(0xFFE3F6EC),
|
||||||
|
warning = Color(0xFF875600),
|
||||||
|
warningPanel = Color(0xFFFFF3D6),
|
||||||
|
danger = Color(0xFFB42318),
|
||||||
|
dangerPanel = Color(0xFFFFE7E5),
|
||||||
|
neutral = Color(0xFF59636E),
|
||||||
|
neutralPanel = Color(0xFFF0F2F4)
|
||||||
|
)
|
||||||
|
|
||||||
|
val darkHermesStatusColors = HermesStatusColors(
|
||||||
|
success = Color(0xFF72D899),
|
||||||
|
successPanel = Color(0xFF183626),
|
||||||
|
warning = Color(0xFFF3C866),
|
||||||
|
warningPanel = Color(0xFF392B10),
|
||||||
|
danger = Color(0xFFFF8A80),
|
||||||
|
dangerPanel = Color(0xFF3A2222),
|
||||||
|
neutral = Color(0xFFADB7C1),
|
||||||
|
neutralPanel = Color(0xFF2F3337)
|
||||||
|
)
|
||||||
+2
-2
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Current Milestone: Native Android Beta Chat and Connection UX
|
## Current Milestone: Native Android Beta Chat and Connection UX
|
||||||
|
|
||||||
Status: in progress, close to beta validation. F0 source landing is complete; Android debug build validation remains environment-blocked.
|
Status: in progress, close to beta validation. F0 source landing and F1 design-system extraction are complete; Android debug builds now pass in the shared Gradle cache environment.
|
||||||
|
|
||||||
Done:
|
Done:
|
||||||
- Native Kotlin/Compose Android shell replaces the web-first mobile path for the beta surface.
|
- Native Kotlin/Compose Android shell replaces the web-first mobile path for the beta surface.
|
||||||
@@ -15,7 +15,7 @@ Done:
|
|||||||
- Companion and workspace TypeScript typecheck, build, and lint pass as of 2026-07-24.
|
- Companion and workspace TypeScript typecheck, build, and lint pass as of 2026-07-24.
|
||||||
|
|
||||||
Remaining for beta:
|
Remaining for beta:
|
||||||
- Validate Android debug build in an environment where Gradle can fetch or reuse the Gradle 8.11.1 distribution. On 2026-07-24, `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :app:assembleDebug` failed before compilation because network socket creation is blocked while downloading `https://services.gradle.org/distributions/gradle-8.11.1-all.zip`.
|
- Android debug build validation passed on 2026-07-24: `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :app:assembleDebug` from `apps/mobile/android`.
|
||||||
- Exercise a real paired device against a running companion and authenticated Hermes CLI.
|
- Exercise a real paired device against a running companion and authenticated Hermes CLI.
|
||||||
- Exercise upstream-compatible transport/auth/session/streaming behavior end to end: bearer auth, health, chat request/response, session continuity expectations, streamed/final output handling, and failure recovery.
|
- Exercise upstream-compatible transport/auth/session/streaming behavior end to end: bearer auth, health, chat request/response, session continuity expectations, streamed/final output handling, and failure recovery.
|
||||||
- Add or run focused Android UI validation for chat/settings flows on small and large screens.
|
- Add or run focused Android UI validation for chat/settings flows on small and large screens.
|
||||||
|
|||||||
Reference in New Issue
Block a user