feat: Material You design system rewrite — Phases 1–4

Complete UI/UX rewrite of hermes-mobile with Material You design system.

Phase 1: Color/Theme/Type/Shapes/Spacing/Motion tokens
Phase 2: HermesScaffold, StateViews (skeleton/empty/error), SearchBar, StatusBadge, ToggleRow
Phase 3: Bottom nav + drawer navigation with NavigationController
Phase 4: All 17 screens rewritten with HermesScaffold and Material You patterns

Closes PR #3.
This commit is contained in:
M57 authored and GitHub committed 2026-06-18 16:34:44 +03:00
1 parent f833005bec
commit d90e36974c
29 files changed
+2669 -2377

No files matched your search

@@ -16,8 +16,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
@@ -28,9 +26,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -44,6 +40,9 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -66,152 +65,137 @@ fun KeysScreen(
}
}
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
if (onOpenDrawer != null) {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = "Open Drawer",
)
}
}
},
title = { Text("Keys & Credentials") },
actions = {
IconButton(onClick = { viewModel.loadKeys() }) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = "Refresh",
)
}
},
)
},
modifier = modifier,
HermesScaffold(
title = "Keys & Credentials",
onOpenDrawer = onOpenDrawer,
onRefresh = { viewModel.loadKeys() },
) { paddingValues ->
Box(
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues),
) {
if (state.isLoading) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else if (state.errorMessage != null) {
Text(
text = state.errorMessage ?: "",
color = MaterialTheme.colorScheme.error,
modifier =
Modifier
.align(Alignment.Center)
.padding(16.dp),
when {
state.isLoading -> {
LoadingState(modifier = Modifier.padding(paddingValues))
}
state.errorMessage != null -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadKeys() },
modifier = Modifier.padding(paddingValues),
)
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(state.envVars.toList()) { (key, config) ->
var isEditing by remember { mutableStateOf(false) }
val revealedVal = state.revealedValues[key]
val isRevealed = revealedVal != null
var editedValue by remember { mutableStateOf(revealedVal ?: "") }
}
else ->
Box(Modifier.fillMaxSize()) {
if (state.isLoading) {
CircularProgressIndicator()
} else if (state.errorMessage != null) {
Text(
text = state.errorMessage ?: "",
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(16.dp),
)
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(state.envVars.toList()) { (key, config) ->
var isEditing by remember { mutableStateOf(false) }
val revealedVal = state.revealedValues[key]
val isRevealed = revealedVal != null
var editedValue by remember { mutableStateOf(revealedVal ?: "") }
val displayValue =
if (isRevealed) {
revealedVal.orEmpty()
} else if (config.isSet) {
config.redactedValue ?: "********"
} else {
"Not configured"
}
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = key,
style = MaterialTheme.typography.titleMedium,
fontFamily = FontFamily.Monospace,
)
if (!config.description.isNullOrBlank()) {
Text(
text = config.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.padding(vertical = 4.dp),
)
}
Spacer(modifier = Modifier.height(4.dp))
if (isEditing) {
OutlinedTextField(
value = editedValue,
onValueChange = { editedValue = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
Button(onClick = {
viewModel.updateKey(key, editedValue)
isEditing = false
}) {
Text("Save")
}
Spacer(modifier = Modifier.width(8.dp))
Button(onClick = {
isEditing = false
}) {
Text("Cancel")
}
val displayValue =
if (isRevealed) {
revealedVal.orEmpty()
} else if (config.isSet) {
config.redactedValue ?: "********"
} else {
"Not configured"
}
} else {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = displayValue,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
text = key,
style = MaterialTheme.typography.titleMedium,
fontFamily = FontFamily.Monospace,
modifier = Modifier.weight(1f),
)
Row {
if (config.isSet) {
IconButton(onClick = {
if (isRevealed) {
viewModel.hideKey(key)
} else {
viewModel.revealKey(key)
}
if (!config.description.isNullOrBlank()) {
Text(
text = config.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.padding(vertical = 4.dp),
)
}
Spacer(modifier = Modifier.height(4.dp))
if (isEditing) {
OutlinedTextField(
value = editedValue,
onValueChange = { editedValue = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
Button(onClick = {
viewModel.updateKey(key, editedValue)
isEditing = false
}) {
Icon(
imageVector =
if (isRevealed) {
Icons.Filled.VisibilityOff
} else {
Icons.Filled.Visibility
},
contentDescription = "Toggle Visibility",
)
Text("Save")
}
Spacer(modifier = Modifier.width(8.dp))
Button(onClick = {
isEditing = false
}) {
Text("Cancel")
}
}
IconButton(onClick = {
editedValue = if (isRevealed) revealedVal.orEmpty() else ""
isEditing = true
}) {
Icon(
imageVector = Icons.Filled.Edit,
contentDescription = "Edit",
} else {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = displayValue,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
modifier = Modifier.weight(1f),
)
Row {
if (config.isSet) {
IconButton(onClick = {
if (isRevealed) {
viewModel.hideKey(key)
} else {
viewModel.revealKey(key)
}
}) {
Icon(
imageVector =
if (isRevealed) {
Icons.Filled.VisibilityOff
} else {
Icons.Filled.Visibility
},
contentDescription = "Toggle Visibility",
)
}
}
IconButton(onClick = {
editedValue = if (isRevealed) revealedVal.orEmpty() else ""
isEditing = true
}) {
Icon(
imageVector = Icons.Filled.Edit,
contentDescription = "Edit",
)
}
}
}
}
}
@@ -220,7 +204,6 @@ fun KeysScreen(
}
}
}
}
}
}
}