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

@@ -17,23 +17,15 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -47,6 +39,9 @@ import androidx.compose.ui.text.font.FontWeight
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
@@ -74,268 +69,158 @@ fun ProfilesScreen(
}
}
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
if (onOpenDrawer != null) {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = "Open Drawer",
)
}
}
},
title = { Text("Agent Profiles") },
actions = {
IconButton(onClick = { viewModel.loadProfiles() }) {
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh")
}
},
)
},
modifier = modifier,
HermesScaffold(
title = "Profiles",
onOpenDrawer = onOpenDrawer,
onRefresh = { viewModel.loadProfiles() },
) { paddingValues ->
Box(
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues),
) {
if (state.isLoading && state.profiles.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else if (state.errorMessage != null && state.profiles.isEmpty()) {
Column(
modifier =
Modifier
.align(Alignment.Center)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { viewModel.loadProfiles() }) {
Text("Retry")
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(state.profiles) { profile ->
val isActive = profile.name == state.activeProfileName
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor =
if (isActive) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
onClick = {
if (!isActive) {
viewModel.selectActiveProfile(profile.name)
}
},
when {
state.isLoading -> {
LoadingState(modifier = Modifier.padding(paddingValues))
}
state.errorMessage != null -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadProfiles() },
modifier = Modifier.padding(paddingValues),
)
}
else ->
Box(Modifier.fillMaxSize()) {
if (state.isLoading && state.profiles.isEmpty()) {
CircularProgressIndicator()
} else if (state.errorMessage != null && state.profiles.isEmpty()) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Row(
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { viewModel.loadProfiles() }) {
Text("Retry")
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(state.profiles) { profile ->
val isActive = profile.name == state.activeProfileName
Card(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
colors =
CardDefaults.cardColors(
containerColor =
if (isActive) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
onClick = {
if (!isActive) {
viewModel.selectActiveProfile(profile.name)
}
},
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = profile.name.replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
profile.description?.let {
if (it.isNotBlank()) {
Spacer(modifier = Modifier.height(4.dp))
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = it,
text = profile.name.replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
profile.description?.let {
if (it.isNotBlank()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
if (isActive) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = "Active Profile",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 8.dp),
)
}
}
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(
text = "Model: ${profile.model ?: "None"}",
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = "Provider: ${profile.provider ?: "None"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
if (isActive) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = "Active Profile",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 8.dp),
)
}
}
Spacer(modifier = Modifier.height(12.dp))
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(
text = "Model: ${profile.model ?: "None"}",
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = "Provider: ${profile.provider ?: "None"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
OutlinedButton(
onClick = {
soulEditProfileName = profile.name
viewModel.loadSoul(profile.name)
},
modifier = Modifier.padding(end = 8.dp),
) {
Icon(
imageVector = Icons.Filled.Edit,
contentDescription = null,
modifier = Modifier.width(16.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text("Edit Soul")
}
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
OutlinedButton(
onClick = {
soulEditProfileName = profile.name
viewModel.loadSoul(profile.name)
},
modifier = Modifier.padding(end = 8.dp),
) {
Icon(
imageVector = Icons.Filled.Edit,
contentDescription = null,
modifier = Modifier.width(16.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text("Edit Soul")
}
Button(
onClick = {
modelEditProfileName = profile.name
tempModelProvider = profile.provider ?: ""
tempModelName = profile.model ?: ""
},
) {
Text("Set Model")
Button(
onClick = {
modelEditProfileName = profile.name
tempModelProvider = profile.provider ?: ""
tempModelName = profile.model ?: ""
},
) {
Text("Set Model")
}
}
}
}
}
}
}
}
}
}
// Soul Edit Dialog
soulEditProfileName?.let { profileName ->
var soulText by remember(state.selectedSoulContent) {
mutableStateOf(state.selectedSoulContent ?: "")
}
AlertDialog(
onDismissRequest = {
soulEditProfileName = null
viewModel.closeSoulDialog()
},
title = { Text("Edit Soul ($profileName)") },
text = {
Box(modifier = Modifier.fillMaxWidth()) {
if (state.isLoadingSoul) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else {
OutlinedTextField(
value = soulText,
onValueChange = { soulText = it },
modifier =
Modifier
.fillMaxWidth()
.height(200.dp),
label = { Text("Soul/Persona Prompts") },
maxLines = 10,
)
}
}
},
confirmButton = {
TextButton(
onClick = {
viewModel.saveSoul(profileName, soulText)
soulEditProfileName = null
},
enabled = !state.isLoadingSoul,
) {
Text("Save")
}
},
dismissButton = {
TextButton(
onClick = {
soulEditProfileName = null
viewModel.closeSoulDialog()
},
) {
Text("Cancel")
}
},
)
}
// Model Edit Dialog
modelEditProfileName?.let { profileName ->
AlertDialog(
onDismissRequest = { modelEditProfileName = null },
title = { Text("Switch Model ($profileName)") },
text = {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = tempModelProvider,
onValueChange = { tempModelProvider = it },
label = { Text("Provider (e.g. nvidia, openai, local)") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = tempModelName,
onValueChange = { tempModelName = it },
label = { Text("Model Name") },
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
TextButton(
onClick = {
viewModel.updateModel(profileName, tempModelProvider, tempModelName)
modelEditProfileName = null
},
) {
Text("Save")
}
},
dismissButton = {
TextButton(onClick = { modelEditProfileName = null }) {
Text("Cancel")
}
},
)
}
}
}