feat(ui): modern theme, sectioned drawer, shared components (v1) (#1)
* feat(ui): modern theme, sectioned drawer, shared components (v1)
Foundation for the modern redesign — fixes the biggest UX problems
(that vague "better ui ux" ticket) without rewriting all 20 screens
at once. This puts the infrastructure in place and modernizes the
highest-traffic screens.
Theme (
- Disable Material You dynamic color by default — brand identity
(Voltage Purple) wins. Can opt back in via HermesControlTheme.
- 3-step elevation ladder (surface / surfaceVariant / surfaceHigh)
with subtle blue tint so dark mode stops reading as flat black.
- Full Material 3 type hierarchy — every style explicitly tuned,
tighter letter spacing, comfortable line heights.
- New Shapes.kt (xs→xl rounded corner tokens) and Spacing.kt
(xs/sm/md/lg/xl/xxl spacing tokens) — no more ad-hoc dp values.
Navigation (
- Sectioned drawer: 17 items were dumped at one indent level with
duplicate icons (Channels & Chat both used the Chat icon!). Now
grouped into 4 logical sections — Converse / Automate / Configure
/ Inspect — with section headers and sane per-item icons.
- Settings added to the drawer (was previously hidden behind
ChatScreen's gear icon — bad IA).
- gesturesEnabled check collapsed from an 18-term OR chain into a
single set-membership test.
Common components (
- HermesScaffold: shared drawer-aware Scaffold + TopAppBar with
slots for refresh / actions. Removes the per-screen duplication
seen in SkillsScreen, CronJobsScreen, ChannelsScreen, etc.
- StateViews: LoadingState / ErrorState / EmptyState / list content
padding tokens — every list screen now renders loading/error/
empty identically instead of ad-hoc per screen.
Modernized screens:
- ConnectScreen: hero brand badge (was a lone "⚡" glyph), pairing
as the primary path, manual token/host/port collapsed behind a
toggle (was always-visible clutter), Card-in-Card nesting removed.
- SkillsScreen: now uses HermesScaffold + EmptyState + ErrorState +
LoadingState. Adds a search field, leading icon per row, and a
category sublabel.
Follow-up commits will migrate the remaining ~15 list screens onto
HermesScaffold; this commit stops at the foundation + the two screens
that see the most daily use.
Verified: ktlint clean across the entire tree.
Co-Authored-By: Ronia <ronia@m57>
* fix(imports): add missing safeDrawingPadding + dp imports
CI caught three compile errors that ktlint doesn't flag (unresolved
references are not lint errors, they're compile errors):
- Navigation.kt:239 — Modifier.safeDrawingPadding() import was
dropped when the file was rewritten for the sectioned drawer.
- SkillsScreen.kt:117 — `16.dp` and `12.dp` references had no
`androidx.compose.ui.unit.dp` import after the file was rewritten
onto HermesScaffold.
No code changes — pure import fixes.
Co-Authored-By: Ronia <ronia@m57>
* ci: split monolithic job into 4 parallel jobs + summary
Before — one job ran ktlint → lint → test → build sequentially. If
ktlint failed you waited ~30s to find out, and the test/build stages
never even started (no useful artifacts uploaded on partial failure).
After — 4 jobs run in parallel on every push and PR:
1. ktlint (fast feedback, ~30s)
2. android-lint (parallel, includes compileDebugKotlin)
3. unit-tests (parallel, JUnit 5 + mockk)
4. build (gated by 1+2+3, only runs if all pass)
Plus a 5th `ci-summary` job that aggregates results so branch
protection only needs a single status check configured.
Other improvements:
- concurrency cancel-in-progress — new commits on the same ref
cancel the stale run instead of piling up.
- timeout-minutes on every job — no more hung runners burning minutes
on a stuck Gradle daemon.
- workflow_dispatch — can trigger manually from the Actions tab.
- Per-job artifact uploads with `if: always()` — reports upload even
when the job fails, so you can download test/lint reports to debug.
- Removed the monolithic "verification-reports" artifact that tried
to upload three unrelated paths and failed silently with "No files
were found" when an early step bailed.
Co-Authored-By: Ronia <ronia@m57>
* ci: silence ktlint artifact-not-found annotation
Adds `if-no-files-found: ignore` to the ktlint artifact upload step
because ktlint 1.2.1 does not produce HTML reports on success — only
on failure. The previous config `if-no-files-found: warn` (default)
emitted a noisy annotation on every green run.
Co-Authored-By: Ronia <ronia@m57>
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
Co-authored-by: Ronia <ronia@m57>
This commit is contained in:
11 files changed
+1013
-548
No files matched your search
@@ -0,0 +1,98 @@
|
||||
package com.m57.hermescontrol.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
/**
|
||||
* Shared Scaffold wrapper that standardizes the drawer-aware TopAppBar.
|
||||
*
|
||||
* Eliminates the per-screen duplication of:
|
||||
* - Menu icon (drawer toggle)
|
||||
* - Title
|
||||
* - Optional Refresh / actions slot
|
||||
*
|
||||
* Screens that still need a Back arrow instead of a Menu icon can set
|
||||
* `showBack = true` and pass an `onBack` lambda.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HermesScaffold(
|
||||
title: String,
|
||||
onOpenDrawer: (() -> Unit)? = null,
|
||||
onRefresh: (() -> Unit)? = null,
|
||||
onBack: (() -> Unit)? = null,
|
||||
showBack: Boolean = false,
|
||||
actions: @Composable () -> Unit = {},
|
||||
content: @Composable (androidx.compose.foundation.layout.PaddingValues) -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
when {
|
||||
showBack && onBack != null -> {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
)
|
||||
}
|
||||
}
|
||||
onOpenDrawer != null -> {
|
||||
IconButton(onClick = onOpenDrawer) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Menu,
|
||||
contentDescription = "Open Drawer",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
title = { Text(title) },
|
||||
actions = {
|
||||
if (onRefresh != null) {
|
||||
IconButton(onClick = onRefresh) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Refresh,
|
||||
contentDescription = "Refresh",
|
||||
)
|
||||
}
|
||||
}
|
||||
actions()
|
||||
},
|
||||
colors =
|
||||
TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
actionIconContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
)
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
) { paddingValues ->
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
) {
|
||||
content(paddingValues)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.m57.hermescontrol.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Inbox
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Unified loading / error / empty placeholders so every list screen
|
||||
* shares the same visual language.
|
||||
*/
|
||||
|
||||
@Composable
|
||||
fun LoadingState(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
strokeWidth = 3.dp,
|
||||
modifier = Modifier.size(40.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ErrorState(
|
||||
message: String,
|
||||
onRetry: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "⚠",
|
||||
fontSize = MaterialTheme.typography.headlineMedium.fontSize,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
if (onRetry != null) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
OutlinedButton(onClick = onRetry) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Refresh,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EmptyState(
|
||||
title: String,
|
||||
subtitle: String? = null,
|
||||
icon: ImageVector = Icons.Filled.Inbox,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||
modifier = Modifier.size(48.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
if (subtitle != null) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Padding convention for List screens — outer 16, gaps of 8 between items.
|
||||
*/
|
||||
val listContentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp)
|
||||
|
||||
val listItemSpacing = Arrangement.spacedBy(8.dp)
|
||||
Reference in new issue
Block a user