diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 988514a8..ee25740d 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -2,26 +2,36 @@ name: Android CI/CD env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + GRADLE_CACHE_ENCRYPTED_KEY: ${{ secrets.GRADLE_ENCRYPTED_KEY }} on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] + workflow_dispatch: + +# Cancel in-progress runs on the same ref when a new commit lands. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true jobs: - build: - name: Build & Test Android App + # ───────────────────────────────────────────────────────────────────── + # Job 1: ktlint (code style) — fastest feedback, runs in parallel. + # ───────────────────────────────────────────────────────────────────── + ktlint: + name: ktlint — Code Style runs-on: ubuntu-latest - + timeout-minutes: 5 steps: - - name: Checkout Source Code + - name: Checkout uses: actions/checkout@v4 - name: Set up JDK 17 uses: actions/setup-java@v4 with: - distribution: 'temurin' + distribution: temurin java-version: '17' - name: Setup Gradle @@ -36,34 +46,146 @@ jobs: chmod +x ktlint sudo mv ktlint /usr/local/bin/ - - name: Run Code Style Check (ktlint) + - name: Run ktlintCheck run: ./gradlew ktlintCheck - - name: Run Android Lint (Static Analysis) - run: ./gradlew lintDebug - - - name: Run Unit & Integration Tests - run: ./gradlew test - - - name: Build Debug APK - run: ./gradlew assembleDebug - - - name: Upload Test & Lint Reports - uses: actions/upload-artifact@v4 + - name: Upload ktlint reports if: always() + uses: actions/upload-artifact@v4 with: - name: verification-reports + name: ktlint-reports path: | - app/build/reports/tests/ - app/build/reports/lint-results-debug.html app/build/reports/ktlint/ + build/reports/ktlint/ + if-no-files-found: ignore retention-days: 7 - - name: Upload Debug APK Artifact + # ───────────────────────────────────────────────────────────────────── + # Job 2: Android Lint (static analysis) — runs in parallel with ktlint. + # Depends on compileDebugKotlin, so it also catches unresolved refs. + # ───────────────────────────────────────────────────────────────────── + android-lint: + name: Android Lint — Static Analysis + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Grant Execute Permission for Gradlew + run: chmod +x gradlew + + - name: Run lintDebug + run: ./gradlew lintDebug + + - name: Upload lint reports + if: always() uses: actions/upload-artifact@v4 + with: + name: lint-reports + path: app/build/reports/lint-results-debug.* + retention-days: 7 + + # ───────────────────────────────────────────────────────────────────── + # Job 3: Unit tests — runs in parallel with ktlint & android-lint. + # ───────────────────────────────────────────────────────────────────── + unit-tests: + name: Unit & Integration Tests + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Grant Execute Permission for Gradlew + run: chmod +x gradlew + + - name: Run test + run: ./gradlew test + + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-reports + path: app/build/reports/tests/ + retention-days: 7 + + # ───────────────────────────────────────────────────────────────────── + # Job 4: Build Debug APK — depends on all three above passing. + # ───────────────────────────────────────────────────────────────────── + build: + name: Build Debug APK + needs: [ktlint, android-lint, unit-tests] + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Grant Execute Permission for Gradlew + run: chmod +x gradlew + + - name: Build assembleDebug + run: ./gradlew assembleDebug + + - name: Upload Debug APK Artifact if: success() + uses: actions/upload-artifact@v4 with: name: hermescontrol-debug-apk path: app/build/outputs/apk/debug/app-debug.apk if-no-files-found: error retention-days: 7 + + # ───────────────────────────────────────────────────────────────────── + # Job 5: PR status summary — posts a single check that aggregates the + # others, so GitHub branch protection only needs one status check. + # ───────────────────────────────────────────────────────────────────── + ci-summary: + name: CI Summary + needs: [ktlint, android-lint, unit-tests, build] + runs-on: ubuntu-latest + timeout-minutes: 2 + if: always() + steps: + - name: Check job results + env: + KTLINT_RESULT: ${{ needs.ktlint.result }} + LINT_RESULT: ${{ needs.android-lint.result }} + TEST_RESULT: ${{ needs.unit-tests.result }} + BUILD_RESULT: ${{ needs.build.result }} + run: | + echo "::notice::ktlint=$KTLINT_RESULT, android-lint=$LINT_RESULT, tests=$TEST_RESULT, build=$BUILD_RESULT" + if [[ "$KTLINT_RESULT" != "success" || "$LINT_RESULT" != "success" || "$TEST_RESULT" != "success" || "$BUILD_RESULT" != "success" ]]; then + echo "::error::One or more CI jobs failed" + exit 1 + fi + echo "All CI jobs passed ✅" diff --git a/app/src/main/java/com/m57/hermescontrol/Navigation.kt b/app/src/main/java/com/m57/hermescontrol/Navigation.kt index c1995954..8e6dfaa3 100644 --- a/app/src/main/java/com/m57/hermescontrol/Navigation.kt +++ b/app/src/main/java/com/m57/hermescontrol/Navigation.kt @@ -1,26 +1,28 @@ package com.m57.hermescontrol +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Chat import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.Build -import androidx.compose.material.icons.filled.CallSplit -import androidx.compose.material.icons.filled.Chat import androidx.compose.material.icons.filled.Code import androidx.compose.material.icons.filled.Dashboard import androidx.compose.material.icons.filled.Devices -import androidx.compose.material.icons.filled.Dns import androidx.compose.material.icons.filled.Extension +import androidx.compose.material.icons.filled.HistoryEdu import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Key -import androidx.compose.material.icons.filled.List -import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.ListAlt +import androidx.compose.material.icons.filled.Memory import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.Webhook import androidx.compose.material3.DrawerValue import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -28,11 +30,14 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.NavigationDrawerItemDefaults import androidx.compose.material3.Text import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider @@ -61,6 +66,64 @@ import com.m57.hermescontrol.ui.system.SystemScreen as SystemScreenContent import com.m57.hermescontrol.ui.toolsets.ToolsetsScreen as ToolsetsScreenContent import com.m57.hermescontrol.ui.webhooks.WebhooksScreen as WebhooksScreenContent +/** + * Drawer sections — the navigation soul of the app. + * + * Each section reflects how the user actually thinks about Hermes: + * CONVERSE → talking to the agent + * AUTOMATE → scheduled + push-driven actions + * CONFIGURE → settings, capabilities, integrations + * INSPECT → observability, ops, achievements + */ +private enum class DrawerSection(val title: String) { + CONVERSE("Converse"), + AUTOMATE("Automate"), + CONFIGURE("Configure"), + INSPECT("Inspect"), +} + +private data class DrawerEntry( + val key: NavKey, + val label: String, + val icon: ImageVector, + val section: DrawerSection, +) + +private val DRAWER_ENTRIES = + listOf( + // Converse + DrawerEntry(ChatScreen, "Chat", Icons.AutoMirrored.Filled.Chat, DrawerSection.CONVERSE), + DrawerEntry(ProfilesScreen, "Profiles", Icons.Filled.AccountCircle, DrawerSection.CONVERSE), + // Automate + DrawerEntry(CronJobsScreen, "Cron Jobs", Icons.Filled.Schedule, DrawerSection.AUTOMATE), + DrawerEntry(WebhooksScreen, "Webhooks", Icons.Filled.Webhook, DrawerSection.AUTOMATE), + DrawerEntry(GatewayScreen, "Gateway", Icons.Filled.Bolt, DrawerSection.AUTOMATE), + // Configure + DrawerEntry(SkillsScreen, "Skills", Icons.Filled.Extension, DrawerSection.CONFIGURE), + DrawerEntry(ToolsetsScreen, "Toolsets", Icons.Filled.Build, DrawerSection.CONFIGURE), + DrawerEntry(PluginsScreen, "Plugins", Icons.Filled.Memory, DrawerSection.CONFIGURE), + DrawerEntry(ConfigScreen, "Config", Icons.Filled.Code, DrawerSection.CONFIGURE), + DrawerEntry(McpServersScreen, "MCP Servers", Icons.Filled.Dashboard, DrawerSection.CONFIGURE), + DrawerEntry(ModelScreen, "Models", Icons.Filled.Settings, DrawerSection.CONFIGURE), + DrawerEntry(PairingScreen, "Pairing", Icons.Filled.Devices, DrawerSection.CONFIGURE), + DrawerEntry(KeysScreen, "Keys", Icons.Filled.Key, DrawerSection.CONFIGURE), + DrawerEntry(ChannelsScreen, "Channels", Icons.Filled.ListAlt, DrawerSection.CONFIGURE), + // Inspect + DrawerEntry(SystemScreen, "System", Icons.Filled.Info, DrawerSection.INSPECT), + DrawerEntry(LogsScreen, "Logs", Icons.Filled.HistoryEdu, DrawerSection.INSPECT), + DrawerEntry(KanbanScreen, "Kanban", Icons.Filled.Dashboard, DrawerSection.INSPECT), + DrawerEntry(AchievementsScreen, "Achievements", Icons.Filled.Info, DrawerSection.INSPECT), + ) + +/** + * Screens where swipe-to-open-drawer is allowed. + * + * Settings is intentionally excluded because it's pushed onto the stack + * (uses Back, not drawer); Connect is the entry gate and has no drawer. + */ +private val DRAWER_GESTURE_SCREENS: Set = + DRAWER_ENTRIES.map { it.key }.toSet() + @Composable fun MainNavigation() { val hasToken = !AuthManager.getToken().isNullOrBlank() @@ -73,16 +136,7 @@ fun MainNavigation() { val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val scope = rememberCoroutineScope() - val gesturesEnabled = - currentScreen == ChatScreen || currentScreen == SkillsScreen || currentScreen == CronJobsScreen || - currentScreen == GatewayScreen || currentScreen == ProfilesScreen || - currentScreen == ToolsetsScreen || currentScreen == AchievementsScreen || - currentScreen == PairingScreen || currentScreen == ConfigScreen || - currentScreen == McpServersScreen || currentScreen == WebhooksScreen || - currentScreen == ModelScreen || currentScreen == LogsScreen || - currentScreen == PluginsScreen || currentScreen == ChannelsScreen || - currentScreen == KeysScreen || currentScreen == SystemScreen || - currentScreen == KanbanScreen + val gesturesEnabled = currentScreen in DRAWER_GESTURE_SCREENS val openDrawer = { scope.launch { drawerState.open() } } ModalNavigationDrawer( @@ -93,192 +147,81 @@ fun MainNavigation() { ModalDrawerSheet( modifier = Modifier.verticalScroll(rememberScrollState()), ) { + // Brand header Text( - text = "Hermes Control", - modifier = Modifier.padding(16.dp), - style = MaterialTheme.typography.titleLarge, + text = "⚡ Hermes", + modifier = Modifier.padding(start = 20.dp, top = 20.dp, end = 16.dp, bottom = 4.dp), + style = + MaterialTheme.typography.headlineSmall.copy( + fontWeight = FontWeight.Bold, + ), + color = MaterialTheme.colorScheme.primary, ) - HorizontalDivider() - NavigationDrawerItem( - icon = { Icon(Icons.Filled.Chat, contentDescription = null) }, - label = { Text("Chat") }, - selected = currentScreen == ChatScreen, - onClick = { - scope.launch { drawerState.close() } - NavigationController.navigateTo(ChatScreen) - }, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - ) - NavigationDrawerItem( - icon = { Icon(Icons.Filled.Extension, contentDescription = null) }, - label = { Text("Skills") }, - selected = currentScreen == SkillsScreen, - onClick = { - scope.launch { drawerState.close() } - NavigationController.navigateTo(SkillsScreen) - }, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - ) - NavigationDrawerItem( - icon = { Icon(Icons.Filled.Schedule, contentDescription = null) }, - label = { Text("Cron Jobs") }, - selected = currentScreen == CronJobsScreen, - onClick = { - scope.launch { drawerState.close() } - NavigationController.navigateTo(CronJobsScreen) - }, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - ) - NavigationDrawerItem( - icon = { Icon(Icons.Filled.AccountCircle, contentDescription = null) }, - label = { Text("Profiles") }, - selected = currentScreen == ProfilesScreen, - onClick = { - scope.launch { drawerState.close() } - NavigationController.navigateTo(ProfilesScreen) - }, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - ) - NavigationDrawerItem( - icon = { Icon(Icons.Filled.Build, contentDescription = null) }, - label = { Text("Toolsets") }, - selected = currentScreen == ToolsetsScreen, - onClick = { - scope.launch { drawerState.close() } - NavigationController.navigateTo(ToolsetsScreen) - }, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - ) - NavigationDrawerItem( - icon = { Icon(Icons.Filled.Star, contentDescription = null) }, - label = { Text("Achievements") }, - selected = currentScreen == AchievementsScreen, - onClick = { - scope.launch { drawerState.close() } - NavigationController.navigateTo(AchievementsScreen) - }, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - ) - NavigationDrawerItem( - icon = { Icon(Icons.Filled.Devices, contentDescription = null) }, - label = { Text("Pairing") }, - selected = currentScreen == PairingScreen, - onClick = { - scope.launch { drawerState.close() } - NavigationController.navigateTo(PairingScreen) - }, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - ) - NavigationDrawerItem( - icon = { Icon(Icons.Filled.Code, contentDescription = null) }, - label = { Text("Config") }, - selected = currentScreen == ConfigScreen, - onClick = { - scope.launch { drawerState.close() } - NavigationController.navigateTo(ConfigScreen) - }, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - ) - NavigationDrawerItem( - icon = { Icon(Icons.Filled.Dns, contentDescription = null) }, - label = { Text("MCP Servers") }, - selected = currentScreen == McpServersScreen, - onClick = { - scope.launch { drawerState.close() } - NavigationController.navigateTo(McpServersScreen) - }, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - ) - NavigationDrawerItem( - icon = { Icon(Icons.Filled.CallSplit, contentDescription = null) }, - label = { Text("Webhooks") }, - selected = currentScreen == WebhooksScreen, - onClick = { - scope.launch { drawerState.close() } - NavigationController.navigateTo(WebhooksScreen) - }, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + Text( + text = "AI Agent Control", + modifier = Modifier.padding(start = 20.dp, bottom = 12.dp, end = 16.dp), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + + // Render grouped entries + for (section in DrawerSection.entries) { + Text( + text = section.title.uppercase(), + modifier = + Modifier.padding( + start = 20.dp, + top = 16.dp, + bottom = 6.dp, + end = 16.dp, + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.SemiBold, + ) + DRAWER_ENTRIES + .filter { it.section == section } + .forEach { entry -> + NavigationDrawerItem( + icon = { Icon(entry.icon, contentDescription = null) }, + label = { Text(entry.label) }, + selected = currentScreen == entry.key, + onClick = { + scope.launch { drawerState.close() } + NavigationController.navigateTo(entry.key) + }, + colors = + NavigationDrawerItemDefaults.colors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedTextColor = MaterialTheme.colorScheme.onPrimaryContainer, + selectedIconColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp), + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + // Settings is always at the bottom — not a section sibling NavigationDrawerItem( icon = { Icon(Icons.Filled.Settings, contentDescription = null) }, - label = { Text("Models") }, - selected = currentScreen == ModelScreen, + label = { Text("Settings") }, + selected = currentScreen == SettingsScreen, onClick = { scope.launch { drawerState.close() } - NavigationController.navigateTo(ModelScreen) + NavigationController.navigateTo(SettingsScreen) }, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - ) - NavigationDrawerItem( - icon = { Icon(Icons.Filled.PlayArrow, contentDescription = null) }, - label = { Text("Gateway") }, - selected = currentScreen == GatewayScreen, - onClick = { - scope.launch { drawerState.close() } - NavigationController.navigateTo(GatewayScreen) - }, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - ) - NavigationDrawerItem( - icon = { Icon(Icons.Filled.List, contentDescription = null) }, - label = { Text("Logs") }, - selected = currentScreen == LogsScreen, - onClick = { - scope.launch { drawerState.close() } - NavigationController.navigateTo(LogsScreen) - }, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - ) - NavigationDrawerItem( - icon = { Icon(Icons.Filled.Extension, contentDescription = null) }, - label = { Text("Plugins") }, - selected = currentScreen == PluginsScreen, - onClick = { - scope.launch { drawerState.close() } - NavigationController.navigateTo(PluginsScreen) - }, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - ) - NavigationDrawerItem( - icon = { Icon(Icons.Filled.Chat, contentDescription = null) }, - label = { Text("Channels") }, - selected = currentScreen == ChannelsScreen, - onClick = { - scope.launch { drawerState.close() } - NavigationController.navigateTo(ChannelsScreen) - }, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - ) - NavigationDrawerItem( - icon = { Icon(Icons.Filled.Key, contentDescription = null) }, - label = { Text("Keys") }, - selected = currentScreen == KeysScreen, - onClick = { - scope.launch { drawerState.close() } - NavigationController.navigateTo(KeysScreen) - }, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - ) - NavigationDrawerItem( - icon = { Icon(Icons.Filled.Info, contentDescription = null) }, - label = { Text("System") }, - selected = currentScreen == SystemScreen, - onClick = { - scope.launch { drawerState.close() } - NavigationController.navigateTo(SystemScreen) - }, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - ) - NavigationDrawerItem( - icon = { Icon(Icons.Filled.Dashboard, contentDescription = null) }, - label = { Text("Kanban") }, - selected = currentScreen == KanbanScreen, - onClick = { - scope.launch { drawerState.close() } - NavigationController.navigateTo(KanbanScreen) - }, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + colors = + NavigationDrawerItemDefaults.colors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedTextColor = MaterialTheme.colorScheme.onPrimaryContainer, + selectedIconColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp), ) + Spacer(modifier = Modifier.height(12.dp)) } } }, diff --git a/app/src/main/java/com/m57/hermescontrol/theme/Color.kt b/app/src/main/java/com/m57/hermescontrol/theme/Color.kt index 7450fa15..9abb7789 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/Color.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/Color.kt @@ -2,42 +2,59 @@ package com.m57.hermescontrol.theme import androidx.compose.ui.graphics.Color -// Primary - Deep Purple -val HermesPurple = Color(0xFF6B4EFF) -val HermesPurpleLight = Color(0xFF9D8AFF) -val HermesPurpleDark = Color(0xFF4A35B3) -val HermesPurple80 = Color(0xFFCFC3FF) +/** + * Hermes Control brand palette — v2 (modern redesign). + * + * Design intent: + * - Primary (voltage purple) is the single accent; surfaces stay calm. + * - Secondary (plasma amber) is reserved for opt-in highlights only. + * - Surfaces use a 3-step elevation ladder with a slight blue tint so dark + * mode no longer reads as flat black. + * - Semantic colors (success/warning/error/info) share the same saturation + * band so they don't fight the brand. + */ -// Secondary - Amber / Gold -val HermesAmber = Color(0xFFFFB300) -val HermesAmberLight = Color(0xFFFFE54C) +val HermesPurple = Color(0xFF7C5CFF) +val HermesPurpleLight = Color(0xFFAC93FF) +val HermesPurpleDark = Color(0xFF5A3FE0) +val HermesPurpleContainer = Color(0xFF2B2159) +val HermesPurpleOnContainer = Color(0xFFD9CCFF) + +val HermesAmber = Color(0xFFFFB627) +val HermesAmberLight = Color(0xFFFFE082) val HermesAmberDark = Color(0xFFC68400) -val HermesAmber80 = Color(0xFFFFE082) -// Dark theme surfaces -val DarkBackground = Color(0xFF121212) -val DarkSurface = Color(0xFF1E1E2E) -val DarkSurfaceVariant = Color(0xFF2A2A3C) -val DarkOnSurface = Color(0xFFE6E1E5) -val DarkOnSurfaceVariant = Color(0xFFCAC4D0) +val DarkBackground = Color(0xFF0B0B11) +val DarkSurface = Color(0xFF121218) +val DarkSurfaceVariant = Color(0xFF1C1C26) +val DarkSurfaceHigh = Color(0xFF262633) +val DarkOnSurface = Color(0xFFE8E6EE) +val DarkOnSurfaceVariant = Color(0xFFB6B2C4) +val DarkOutline = Color(0xFF4A4A5A) +val DarkOutlineVariant = Color(0xFF2F2F3D) -// Light theme surfaces -val LightBackground = Color(0xFFFFFBFE) -val LightSurface = Color(0xFFFFFBFE) -val LightSurfaceVariant = Color(0xFFE7E0EC) -val LightOnSurface = Color(0xFF1C1B1F) -val LightOnSurfaceVariant = Color(0xFF49454F) +val LightBackground = Color(0xFFF7F6FB) +val LightSurface = Color(0xFFFFFFFF) +val LightSurfaceVariant = Color(0xFFEFEDF4) +val LightSurfaceHigh = Color(0xFFE6E3EE) +val LightOnSurface = Color(0xFF1A1A24) +val LightOnSurfaceVariant = Color(0xFF56536A) +val LightOutline = Color(0xFF79748E) +val LightOutlineVariant = Color(0xFFC9C5D6) -// Status colors -val StatusGreen = Color(0xFF4CAF50) -val StatusRed = Color(0xFFEF5350) -val StatusYellow = Color(0xFFFFC107) +val StatusGreen = Color(0xFF3DDC84) +val StatusGreenContainer = Color(0xFF143A23) +val StatusRed = Color(0xFFFF5C5C) +val StatusRedContainer = Color(0xFF3D1414) +val StatusYellow = Color(0xFFFFB627) +val StatusYellowContainer = Color(0xFF3D2F0F) +val StatusBlue = Color(0xFF4DA8FF) +val StatusBlueContainer = Color(0xFF0F2A3D) -// Chat bubble colors -val UserBubble = Color(0xFF6B4EFF) -val UserBubbleDark = Color(0xFF5A3FE0) -val AssistantBubble = Color(0xFF2A2A3C) -val AssistantBubbleLight = Color(0xFFE7E0EC) -val SystemMessageColor = Color(0xFF9E9E9E) -val ToolChipColor = Color(0xFF37474F) -val ToolChipColorLight = Color(0xFFECEFF1) +val UserBubble = HermesPurple +val UserBubbleDark = HermesPurpleDark +val AssistantBubble = Color(0xFF1C1C26) +val AssistantBubbleLight = Color(0xFFEDEAF4) +val SystemMessageColor = Color(0xFF8B879A) +val ToolChipColor = Color(0xFF262633) +val ToolChipColorLight = Color(0xFFE6E3EE) diff --git a/app/src/main/java/com/m57/hermescontrol/theme/Shapes.kt b/app/src/main/java/com/m57/hermescontrol/theme/Shapes.kt new file mode 100644 index 00000000..8da1555f --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/theme/Shapes.kt @@ -0,0 +1,23 @@ +package com.m57.hermescontrol.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +import androidx.compose.ui.unit.dp + +/** + * Unified shape system for Hermes Control. + * + * - xs: chips, small badges + * - sm: text fields, small cards + * - md: standard cards, list rows + * - lg: large cards, bottom sheets + * - xl: feature cards, full-bleed surfaces + */ +val HermesShapes = + Shapes( + extraSmall = RoundedCornerShape(6.dp), + small = RoundedCornerShape(10.dp), + medium = RoundedCornerShape(16.dp), + large = RoundedCornerShape(24.dp), + extraLarge = RoundedCornerShape(32.dp), + ) diff --git a/app/src/main/java/com/m57/hermescontrol/theme/Spacing.kt b/app/src/main/java/com/m57/hermescontrol/theme/Spacing.kt new file mode 100644 index 00000000..e421f76b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/theme/Spacing.kt @@ -0,0 +1,24 @@ +package com.m57.hermescontrol.theme + +import androidx.compose.ui.unit.dp + +/** + * Spacing tokens — use these instead of ad-hoc dp values so every screen + * shares the same rhythm. + * + * Convention (Material 3 spacing scale, condensed): + * xs = 4 hairline / chip gaps + * sm = 8 default inner padding + * md = 12 card content + * lg = 16 screen edge, section gaps + * xl = 24 hero spacing + * xxl = 32 full-feature spacing + */ +object Spacing { + val xs = 4.dp + val sm = 8.dp + val md = 12.dp + val lg = 16.dp + val xl = 24.dp + val xxl = 32.dp +} diff --git a/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt b/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt index ea1d89b3..487bff3c 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt @@ -16,16 +16,22 @@ enum class ThemePreference { SYSTEM, LIGHT, DARK } val LocalThemePreference = compositionLocalOf { ThemePreference.SYSTEM } +/** + * Set to true to opt into Material You dynamic color (Android 12+). + * Off by default — brand identity (Voltage Purple) is the priority. + */ +private const val ENABLE_DYNAMIC_COLOR = false + private val HermesDarkColorScheme = darkColorScheme( primary = HermesPurple, onPrimary = Color.White, - primaryContainer = HermesPurpleDark, - onPrimaryContainer = HermesPurple80, + primaryContainer = HermesPurpleContainer, + onPrimaryContainer = HermesPurpleOnContainer, secondary = HermesAmber, onSecondary = Color.Black, - secondaryContainer = HermesAmberDark, - onSecondaryContainer = HermesAmber80, + secondaryContainer = Color(0xFF3D2F0F), + onSecondaryContainer = HermesAmberLight, tertiary = HermesAmberLight, background = DarkBackground, onBackground = DarkOnSurface, @@ -33,21 +39,30 @@ private val HermesDarkColorScheme = onSurface = DarkOnSurface, surfaceVariant = DarkSurfaceVariant, onSurfaceVariant = DarkOnSurfaceVariant, - error = Color(0xFFCF6679), - onError = Color.Black, - outline = Color(0xFF938F99), + surfaceTint = HermesPurple, + surfaceContainer = DarkSurfaceVariant, + surfaceContainerHigh = DarkSurfaceHigh, + surfaceContainerHighest = DarkSurfaceHigh, + inverseSurface = Color(0xFFE8E6EE), + inverseOnSurface = Color(0xFF1A1A24), + error = StatusRed, + onError = Color.White, + errorContainer = StatusRedContainer, + onErrorContainer = Color(0xFFFFB4B4), + outline = DarkOutline, + outlineVariant = DarkOutlineVariant, ) private val HermesLightColorScheme = lightColorScheme( - primary = HermesPurple, + primary = HermesPurpleDark, onPrimary = Color.White, - primaryContainer = HermesPurple80, - onPrimaryContainer = HermesPurpleDark, - secondary = HermesAmber, - onSecondary = Color.Black, - secondaryContainer = HermesAmber80, - onSecondaryContainer = HermesAmberDark, + primaryContainer = HermesPurpleLight, + onPrimaryContainer = Color(0xFF1E0F66), + secondary = HermesAmberDark, + onSecondary = Color.White, + secondaryContainer = HermesAmberLight, + onSecondaryContainer = Color(0xFF3D2F0F), tertiary = HermesAmberDark, background = LightBackground, onBackground = LightOnSurface, @@ -55,14 +70,24 @@ private val HermesLightColorScheme = onSurface = LightOnSurface, surfaceVariant = LightSurfaceVariant, onSurfaceVariant = LightOnSurfaceVariant, - error = Color(0xFFB00020), + surfaceTint = HermesPurple, + surfaceContainer = LightSurfaceVariant, + surfaceContainerHigh = LightSurfaceHigh, + surfaceContainerHighest = LightSurfaceHigh, + inverseSurface = Color(0xFF1A1A24), + inverseOnSurface = Color(0xFFE8E6EE), + error = Color(0xFFB3261E), onError = Color.White, - outline = Color(0xFF79747E), + errorContainer = Color(0xFFF9DEDC), + onErrorContainer = Color(0xFF410E0B), + outline = LightOutline, + outlineVariant = LightOutlineVariant, ) @Composable fun HermesControlTheme( themePreference: ThemePreference = LocalThemePreference.current, + enableDynamicColor: Boolean = ENABLE_DYNAMIC_COLOR, content: @Composable () -> Unit, ) { val darkTheme = @@ -73,11 +98,11 @@ fun HermesControlTheme( } val context = LocalContext.current - val dynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + val dynamicAvailable = enableDynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S val colorScheme = when { - dynamicColor && darkTheme -> dynamicDarkColorScheme(context) - dynamicColor && !darkTheme -> dynamicLightColorScheme(context) + dynamicAvailable && darkTheme -> dynamicDarkColorScheme(context) + dynamicAvailable && !darkTheme -> dynamicLightColorScheme(context) darkTheme -> HermesDarkColorScheme else -> HermesLightColorScheme } @@ -85,6 +110,7 @@ fun HermesControlTheme( MaterialTheme( colorScheme = colorScheme, typography = Typography, + shapes = HermesShapes, content = content, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/theme/Type.kt b/app/src/main/java/com/m57/hermescontrol/theme/Type.kt index 1c095282..7d534f4d 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/Type.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/Type.kt @@ -6,31 +6,138 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp -// Set of Material typography styles to start with +/** + * Full Material 3 type hierarchy — every style explicitly tuned so the + * app stops inheriting Compose defaults. + * + * Letter spacing is tighter than M3 defaults (modern look), line heights are + * comfortable for long-form. + */ val Typography = Typography( + // Display — hero numbers, big stats + displayLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 44.sp, + lineHeight = 52.sp, + letterSpacing = (-0.5).sp, + ), + displayMedium = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 36.sp, + lineHeight = 44.sp, + letterSpacing = (-0.25).sp, + ), + displaySmall = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 30.sp, + lineHeight = 38.sp, + letterSpacing = 0.sp, + ), + // Headline — empty-state titles, screen intros + headlineLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 28.sp, + lineHeight = 34.sp, + letterSpacing = 0.sp, + ), + headlineMedium = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 30.sp, + letterSpacing = 0.sp, + ), + headlineSmall = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, + lineHeight = 26.sp, + letterSpacing = 0.1.sp, + ), + // Title — TopAppBar, card headings + titleLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, + lineHeight = 26.sp, + letterSpacing = 0.1.sp, + ), + titleMedium = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 22.sp, + letterSpacing = 0.15.sp, + ), + titleSmall = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + // Body — primary reading bodyLarge = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp, + letterSpacing = 0.25.sp, + ), + bodyMedium = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.2.sp, + ), + bodySmall = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp, + ), + // Label — buttons, chips, captions + labelLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + labelMedium = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), + labelSmall = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, letterSpacing = 0.5.sp, ), - /* Other default text styles to override - titleLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 22.sp, - lineHeight = 28.sp, - letterSpacing = 0.sp - ), - labelSmall = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 11.sp, - lineHeight = 16.sp, - letterSpacing = 0.5.sp - ) - */ ) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/HermesScaffold.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/HermesScaffold.kt new file mode 100644 index 00000000..e2ac7147 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/HermesScaffold.kt @@ -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) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/StateViews.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/StateViews.kt new file mode 100644 index 00000000..a8d2f0e8 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/StateViews.kt @@ -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) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectScreen.kt index e700ba5a..1e76e00a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectScreen.kt @@ -3,7 +3,6 @@ package com.m57.hermescontrol.ui.connect import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -16,22 +15,22 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -40,14 +39,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.draw.clip 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.TextAlign import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel @@ -66,22 +64,13 @@ fun ConnectScreen( } var passwordVisible by remember { mutableStateOf(false) } - - val backgroundGradient = - Brush.verticalGradient( - colors = - listOf( - MaterialTheme.colorScheme.background, - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), - MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), - ), - ) + var showManualFields by remember { mutableStateOf(false) } Box( modifier = modifier .fillMaxSize() - .background(backgroundGradient) + .background(MaterialTheme.colorScheme.background) .imePadding(), contentAlignment = Alignment.Center, ) { @@ -90,204 +79,165 @@ fun ConnectScreen( Modifier .fillMaxWidth() .verticalScroll(rememberScrollState()) - .padding(24.dp), + .padding(horizontal = 24.dp, vertical = 32.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, + verticalArrangement = Arrangement.spacedBy(12.dp), ) { - // Branding - Text( - text = "⚡", - fontSize = 64.sp, - textAlign = TextAlign.Center, - ) - Spacer(modifier = Modifier.height(8.dp)) + // Hero brand + Box( + modifier = + Modifier + .size(80.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.Bolt, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(44.dp), + ) + } + Text( text = "Hermes", style = - MaterialTheme.typography.headlineLarge.copy( + MaterialTheme.typography.displayMedium.copy( fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, + color = MaterialTheme.colorScheme.onBackground, ), ) Text( text = "AI Agent Control", style = - MaterialTheme.typography.titleMedium.copy( + MaterialTheme.typography.bodyLarge.copy( color = MaterialTheme.colorScheme.onSurfaceVariant, ), ) - Spacer(modifier = Modifier.height(32.dp)) + Spacer(modifier = Modifier.height(24.dp)) - // Connection Card - Card( + // Pairing (primary path) + Column( modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), - ), - border = - BorderStroke( - width = 1.dp, - color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f), - ), - elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { - Column( + Text( + text = "Pairing string", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + var pairingString by remember { mutableStateOf("") } + OutlinedTextField( + value = pairingString, + onValueChange = { + pairingString = it + if (it.isNotBlank()) viewModel.onPairingString(it) + }, + placeholder = { Text("Paste hermes://… or Base64 config") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + // Error message + AnimatedVisibility( + visible = state.errorMessage != null, + enter = fadeIn(), + exit = fadeOut(), + ) { + state.errorMessage?.let { error -> + Text( + text = error, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Start, + modifier = Modifier.fillMaxWidth(), + ) + } + } + + Button( + onClick = viewModel::connect, + enabled = !state.isConnecting, modifier = Modifier .fillMaxWidth() - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + .height(52.dp), + ) { + if (state.isConnecting) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp, + ) + } else { + Text("Connect", style = MaterialTheme.typography.labelLarge) + } + } + + TextButton( + onClick = { showManualFields = !showManualFields }, + modifier = Modifier.fillMaxWidth(), ) { Text( - text = "Connect to Hermes", - style = - MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.SemiBold, - ), + text = if (showManualFields) "Hide manual entry" else "Enter manually instead", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, ) + } - var pairingString by remember { mutableStateOf("") } - - OutlinedTextField( - value = pairingString, - onValueChange = { - pairingString = it - if (it.isNotBlank()) { - viewModel.onPairingString(it) - } - }, - label = { Text("Pairing Link / Connection String") }, - placeholder = { Text("Paste hermes://... or Base64 config") }, - singleLine = true, + AnimatedVisibility(visible = showManualFields) { + Column( modifier = Modifier.fillMaxWidth(), - colors = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.secondary, - cursorColor = MaterialTheme.colorScheme.secondary, - ), - ) - - // Token field - OutlinedTextField( - value = state.token, - onValueChange = viewModel::onTokenChange, - label = { Text("Auth Token") }, - placeholder = { Text("Enter your API token") }, - singleLine = true, - visualTransformation = - if (passwordVisible) { - VisualTransformation.None - } else { - PasswordVisualTransformation() + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + HorizontalDivider() + OutlinedTextField( + value = state.token, + onValueChange = viewModel::onTokenChange, + label = { Text("Auth Token") }, + placeholder = { Text("API token") }, + singleLine = true, + visualTransformation = + if (passwordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = + if (passwordVisible) { + Icons.Filled.Visibility + } else { + Icons.Filled.VisibilityOff + }, + contentDescription = + if (passwordVisible) "Hide token" else "Show token", + ) + } }, - trailingIcon = { - IconButton(onClick = { passwordVisible = !passwordVisible }) { - Icon( - imageVector = - if (passwordVisible) { - Icons.Filled.Visibility - } else { - Icons.Filled.VisibilityOff - }, - contentDescription = - if (passwordVisible) { - "Hide token" - } else { - "Show token" - }, - ) - } - }, - modifier = Modifier.fillMaxWidth(), - colors = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - cursorColor = MaterialTheme.colorScheme.primary, - ), - ) - - // Host field - OutlinedTextField( - value = state.host, - onValueChange = viewModel::onHostChange, - label = { Text("Host") }, - placeholder = { Text("127.0.0.1") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - colors = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - cursorColor = MaterialTheme.colorScheme.primary, - ), - ) - - // Port field - OutlinedTextField( - value = state.port, - onValueChange = viewModel::onPortChange, - label = { Text("Port") }, - placeholder = { Text("9119") }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth(), - colors = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - cursorColor = MaterialTheme.colorScheme.primary, - ), - ) - - // Error message - AnimatedVisibility( - visible = state.errorMessage != null, - enter = fadeIn(), - exit = fadeOut(), - ) { - state.errorMessage?.let { error -> - Card( - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - ), - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = error, - color = MaterialTheme.colorScheme.onErrorContainer, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(12.dp), - ) - } - } - } - - // Connect button - Button( - onClick = viewModel::connect, - enabled = !state.isConnecting, - modifier = - Modifier - .fillMaxWidth() - .height(52.dp), - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - ) { - if (state.isConnecting) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - color = MaterialTheme.colorScheme.onPrimary, - strokeWidth = 2.dp, - ) - } else { - Text( - text = "Connect", - style = MaterialTheme.typography.labelLarge, - ) - } + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = state.host, + onValueChange = viewModel::onHostChange, + label = { Text("Host") }, + placeholder = { Text("127.0.0.1") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = state.port, + onValueChange = viewModel::onPortChange, + label = { Text("Port") }, + placeholder = { Text("9119") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt index fb2c73a1..26631ead 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt @@ -2,41 +2,40 @@ package com.m57.hermescontrol.ui.skills import android.widget.Toast 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.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.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Extension import androidx.compose.material3.Card -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch 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 +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +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.EmptyState +import com.m57.hermescontrol.ui.common.ErrorState +import com.m57.hermescontrol.ui.common.HermesScaffold +import com.m57.hermescontrol.ui.common.LoadingState +import com.m57.hermescontrol.ui.common.listContentPadding +import com.m57.hermescontrol.ui.common.listItemSpacing -@OptIn(ExperimentalMaterial3Api::class) @Composable fun SkillsScreen( modifier: Modifier = Modifier, @@ -45,6 +44,7 @@ fun SkillsScreen( ) { val state by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current + var query by remember { mutableStateOf("") } LaunchedEffect(Unit) { viewModel.loadSkills() @@ -57,57 +57,60 @@ fun SkillsScreen( } } - Scaffold( - topBar = { - TopAppBar( - navigationIcon = { - if (onOpenDrawer != null) { - IconButton(onClick = onOpenDrawer) { - Icon( - imageVector = androidx.compose.material.icons.Icons.Filled.Menu, - contentDescription = "Open Drawer", - ) - } - } - }, - title = { Text("Skills Management") }, - actions = { - IconButton(onClick = { viewModel.loadSkills() }) { - Icon( - imageVector = Icons.Filled.Refresh, - contentDescription = "Refresh", + val filtered = + if (query.isBlank()) { + state.skills + } else { + state.skills.filter { + it.name.contains(query, ignoreCase = true) || + it.category?.contains(query, ignoreCase = true) == true || + it.description?.contains(query, ignoreCase = true) == true + } + } + + HermesScaffold( + title = "Skills", + onOpenDrawer = onOpenDrawer, + onRefresh = { viewModel.loadSkills() }, + ) { + when { + state.isLoading -> LoadingState() + state.errorMessage != null -> + ErrorState( + message = state.errorMessage ?: "Unknown error", + onRetry = { viewModel.loadSkills() }, + ) + state.skills.isEmpty() -> + EmptyState( + title = "No skills", + subtitle = "Tap refresh to retry loading skills from Hermes.", + icon = Icons.Filled.Extension, + ) + else -> + LazyColumn( + modifier = Modifier.fillMaxWidth(), + contentPadding = listContentPadding, + verticalArrangement = listItemSpacing, + ) { + // Search field + item { + androidx.compose.material3.OutlinedTextField( + value = query, + onValueChange = { query = it }, + placeholder = { Text("Search skills…") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), ) } - }, - ) - }, - modifier = modifier, - ) { 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), - ) - } else { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(state.skills) { skill -> - Card(modifier = Modifier.fillMaxWidth()) { + // Filtered list + items(filtered) { skill -> + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { Row( modifier = Modifier @@ -116,18 +119,40 @@ fun SkillsScreen( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Column(modifier = Modifier.weight(1f)) { - Text(text = skill.name, style = MaterialTheme.typography.titleMedium) - Text( - text = skill.category ?: "", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = skill.description ?: "", - style = MaterialTheme.typography.bodyMedium, + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Icons.Filled.Extension, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = skill.name, + style = + MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + ) + skill.category?.let { cat -> + Text( + text = cat, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + skill.description?.let { desc -> + Text( + text = desc, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + ) + } + } } Switch( checked = skill.enabled, @@ -137,7 +162,6 @@ fun SkillsScreen( } } } - } } } }