Add Compose screens with mock data, Roborazzi screenshot tests, and HTML dashboard

13 Compose screens with 27 state variations matching the UI design doc:
- WelcomeScreen, MainDashboardScreen (6 states), AddIntegrationPickerScreen,
  NotificationPreferencesScreen, SettingUpScreen (3 states),
  IntegrationEnabledScreen, IntegrationManageScreen (2 states),
  DeviceCodeApprovalScreen (2 states), ConnectionConfirmedScreen,
  AuditHistoryScreen (3 states), SettingsScreen (2 states),
  HealthConnectSetupScreen, HealthConnectSettingsScreen

Roborazzi screenshot testing:
- 27 screenshot tests capturing every screen state
- Baseline screenshots in app/screenshots/
- Record: ./gradlew :app:recordRoborazziDebug
- Verify: ./gradlew :app:verifyRoborazziDebug

HTML dashboard (app/screenshots/dashboard.html) showing all screens
organized by flow.

Build config:
- Added Compose BOM, Material 3, Navigation Compose, Roborazzi deps
- Kotlin Compose plugin enabled
- Detekt/ktlint configured for Composable functions
- Placeholder launcher icon (fixes pre-existing build issue)
- BouncyCastle META-INF packaging exclusion

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jason Monk 2026-04-04 16:50:39 -04:00
parent fa87318b53
commit 720d33242d
50 changed files with 3475 additions and 2 deletions

View file

@ -10,3 +10,4 @@ trim_trailing_whitespace = true
[*.{kt,kts}]
ktlint_code_style = android_studio
ktlint_standard_function-naming = disabled

View file

@ -1,6 +1,8 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.roborazzi)
}
android {
@ -13,6 +15,8 @@ android {
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
@ -33,6 +37,25 @@ android {
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
packaging {
resources {
excludes += "META-INF/versions/9/OSGI-INF/MANIFEST.MF"
}
}
testOptions {
unitTests {
isIncludeAndroidResources = true
all {
it.systemProperty("robolectric.graphicsMode", "NATIVE")
}
}
}
}
dependencies {
@ -45,4 +68,24 @@ dependencies {
implementation(libs.androidx.activity)
implementation(libs.androidx.lifecycle.runtime)
implementation(libs.material)
// Compose
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.ui.graphics)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3)
implementation(libs.compose.material.icons)
implementation(libs.activity.compose)
implementation(libs.navigation.compose)
debugImplementation(libs.compose.ui.tooling)
debugImplementation(libs.compose.ui.test.manifest)
// Test
testImplementation(libs.junit)
testImplementation(libs.robolectric)
testImplementation(libs.roborazzi)
testImplementation(libs.roborazzi.compose)
testImplementation(libs.roborazzi.junit.rule)
testImplementation(libs.compose.ui.test.junit4)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,191 @@
package com.rousecontext.app.ui.screens
import androidx.compose.foundation.layout.Column
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.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
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.Immutable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.rousecontext.app.ui.theme.RouseContextTheme
enum class PickerIntegrationState { AVAILABLE, DISABLED, UNAVAILABLE }
@Immutable
data class PickerIntegration(
val id: String,
val name: String,
val description: String,
val state: PickerIntegrationState
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddIntegrationPickerScreen(
integrations: List<PickerIntegration> = emptyList(),
onSetUp: (String) -> Unit = {},
onReEnable: (String) -> Unit = {},
onCancel: () -> Unit = {}
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Add Integration") },
navigationIcon = {
IconButton(onClick = onCancel) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp)
) {
LazyColumn(modifier = Modifier.weight(1f)) {
items(integrations) { integration ->
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
colors = if (integration.state == PickerIntegrationState.UNAVAILABLE) {
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(
alpha = 0.5f
)
)
} else {
CardDefaults.cardColors()
}
) {
Column(modifier = Modifier.padding(16.dp)) {
val isUnavailable =
integration.state == PickerIntegrationState.UNAVAILABLE
Text(
text = if (isUnavailable) {
"${integration.name} (coming soon)"
} else {
integration.name
},
style = MaterialTheme.typography.titleMedium,
color = if (isUnavailable) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurface
}
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = integration.description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
when (integration.state) {
PickerIntegrationState.AVAILABLE -> {
Button(onClick = { onSetUp(integration.id) }) {
Text("Set up")
}
}
PickerIntegrationState.DISABLED -> {
OutlinedButton(onClick = { onReEnable(integration.id) }) {
Text("Re-enable")
}
}
PickerIntegrationState.UNAVAILABLE -> {
OutlinedButton(onClick = {}, enabled = false) {
Text("Soon")
}
}
}
}
}
if (integration != integrations.last()) {
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
}
}
}
TextButton(
onClick = onCancel,
modifier = Modifier
.align(Alignment.End)
.padding(bottom = 16.dp)
) {
Text("Cancel")
}
}
}
}
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun AddIntegrationPickerPreview() {
RouseContextTheme(dynamicColor = false) {
AddIntegrationPickerScreen(
integrations = listOf(
PickerIntegration(
id = "health",
name = "Health Connect",
description = "Share step count, heart rate, and sleep data with AI clients",
state = PickerIntegrationState.AVAILABLE
),
PickerIntegration(
id = "notifications",
name = "Notifications",
description = "Let AI clients read your notifications",
state = PickerIntegrationState.UNAVAILABLE
)
)
)
}
}
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun AddIntegrationPickerWithDisabledPreview() {
RouseContextTheme(dynamicColor = false) {
AddIntegrationPickerScreen(
integrations = listOf(
PickerIntegration(
id = "health",
name = "Health Connect",
description = "Share step count, heart rate, and sleep data with AI clients",
state = PickerIntegrationState.DISABLED
),
PickerIntegration(
id = "notifications",
name = "Notifications",
description = "Let AI clients read your notifications",
state = PickerIntegrationState.UNAVAILABLE
)
)
)
}
}

View file

@ -0,0 +1,326 @@
package com.rousecontext.app.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Card
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
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.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.rousecontext.app.ui.theme.RouseContextTheme
@Immutable
data class AuditHistoryEntry(
val time: String,
val toolName: String,
val durationMs: Long,
val arguments: String
)
@Immutable
data class AuditHistoryGroup(
val dateLabel: String,
val entries: List<AuditHistoryEntry>
)
@Immutable
data class AuditHistoryState(
val groups: List<AuditHistoryGroup> = emptyList(),
val providerFilter: String = "All providers",
val dateFilter: String = "Today",
val availableProviders: List<String> = listOf("All providers", "health"),
val availableDates: List<String> = listOf("Today", "Yesterday", "Last 7 days", "Last 30 days")
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AuditHistoryScreen(
state: AuditHistoryState = AuditHistoryState(),
onProviderFilterChanged: (String) -> Unit = {},
onDateFilterChanged: (String) -> Unit = {},
onClearHistory: () -> Unit = {},
onTabSelected: (Int) -> Unit = {}
) {
Scaffold(
topBar = {
TopAppBar(title = { Text("Audit History") })
},
bottomBar = {
NavigationBar {
NavigationBarItem(
selected = false,
onClick = { onTabSelected(0) },
icon = { Icon(Icons.Default.Home, contentDescription = "Home") },
label = { Text("Home") }
)
NavigationBarItem(
selected = true,
onClick = { onTabSelected(1) },
icon = { Icon(Icons.Default.History, contentDescription = "Audit") },
label = { Text("Audit") }
)
NavigationBarItem(
selected = false,
onClick = { onTabSelected(2) },
icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
label = { Text("Settings") }
)
}
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp)
) {
// Filters
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
FilterDropdown(
label = "Provider",
selected = state.providerFilter,
options = state.availableProviders,
onSelected = onProviderFilterChanged,
modifier = Modifier.weight(1f)
)
FilterDropdown(
label = "Date",
selected = state.dateFilter,
options = state.availableDates,
onSelected = onDateFilterChanged,
modifier = Modifier.weight(1f)
)
}
Spacer(modifier = Modifier.height(16.dp))
if (state.groups.isEmpty()) {
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "No audit entries yet.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Activity will appear here after AI clients access your data.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
} else {
LazyColumn(modifier = Modifier.weight(1f)) {
state.groups.forEach { group ->
item {
Text(
text = group.dateLabel,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(vertical = 8.dp)
)
}
item {
Card(modifier = Modifier.fillMaxWidth()) {
Column {
group.entries.forEachIndexed { index, entry ->
Column(modifier = Modifier.padding(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
entry.time,
style = MaterialTheme.typography.bodySmall
)
Text(
entry.toolName,
style = MaterialTheme.typography.bodyMedium
)
}
Row {
Text(
"${entry.durationMs}ms",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme
.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(8.dp))
Text(
"args: ${entry.arguments}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme
.colorScheme.onSurfaceVariant
)
}
}
if (index < group.entries.lastIndex) {
HorizontalDivider()
}
}
}
}
}
}
}
OutlinedButton(
onClick = onClearHistory,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp)
) {
Text("Clear history")
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun FilterDropdown(
label: String,
selected: String,
options: List<String>,
onSelected: (String) -> Unit,
modifier: Modifier = Modifier
) {
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
modifier = modifier
) {
TextField(
value = selected,
onValueChange = {},
readOnly = true,
label = { Text(label) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable)
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
options.forEach { option ->
DropdownMenuItem(
text = { Text(option) },
onClick = {
onSelected(option)
expanded = false
}
)
}
}
}
}
// --- Previews ---
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun AuditHistoryPopulatedPreview() {
RouseContextTheme(dynamicColor = false) {
AuditHistoryScreen(
state = AuditHistoryState(
groups = listOf(
AuditHistoryGroup(
dateLabel = "Today",
entries = listOf(
AuditHistoryEntry("10:32 AM", "health/get_steps", 142, "{days: 7}"),
AuditHistoryEntry("10:31 AM", "health/get_sleep", 89, "{days: 1}"),
AuditHistoryEntry(
"10:31 AM",
"health/get_heart_rate",
201,
"{days: 7}"
)
)
),
AuditHistoryGroup(
dateLabel = "Yesterday",
entries = listOf(
AuditHistoryEntry("3:15 PM", "health/get_steps", 156, "{days: 30}")
)
)
)
)
)
}
}
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun AuditHistoryEmptyPreview() {
RouseContextTheme(dynamicColor = false) {
AuditHistoryScreen()
}
}
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun AuditHistoryFilteredPreview() {
RouseContextTheme(dynamicColor = false) {
AuditHistoryScreen(
state = AuditHistoryState(
providerFilter = "health",
dateFilter = "Last 7 days",
groups = listOf(
AuditHistoryGroup(
dateLabel = "Today",
entries = listOf(
AuditHistoryEntry("10:32 AM", "health/get_steps", 142, "{days: 7}")
)
)
)
)
)
}
}

View file

@ -0,0 +1,71 @@
package com.rousecontext.app.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.CheckCircle
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.rousecontext.app.ui.theme.RouseContextTheme
@Composable
fun ConnectionConfirmedScreen(onBackToHome: () -> Unit = {}) {
Surface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = "Connected",
modifier = Modifier.size(72.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Connected",
style = MaterialTheme.typography.headlineMedium
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Your AI client is now authorized.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(48.dp))
Button(onClick = onBackToHome) {
Text("Back to Home")
}
}
}
}
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun ConnectionConfirmedPreview() {
RouseContextTheme(dynamicColor = false) {
ConnectionConfirmedScreen()
}
}

View file

@ -0,0 +1,183 @@
package com.rousecontext.app.ui.screens
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
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.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.rousecontext.app.ui.theme.RouseContextTheme
@Immutable
data class DeviceCodeApprovalState(
val integrationName: String = "Health Connect",
val codeLength: Int = 8,
val enteredCode: String = "",
val isApproving: Boolean = false
)
@Composable
fun DeviceCodeApprovalScreen(
state: DeviceCodeApprovalState = DeviceCodeApprovalState(),
onCodeChanged: (String) -> Unit = {},
onApprove: () -> Unit = {},
onDeny: () -> Unit = {}
) {
var code by remember(state.enteredCode) { mutableStateOf(state.enteredCode) }
Surface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Approve Connection",
style = MaterialTheme.typography.headlineMedium
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "An AI client wants to access ${state.integrationName}.",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Enter the code shown by the client:",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
// Code input boxes
CodeInputRow(
code = code,
length = state.codeLength,
onCodeChanged = { newCode ->
if (newCode.length <= state.codeLength) {
code = newCode
onCodeChanged(newCode)
}
}
)
Spacer(modifier = Modifier.height(32.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Button(
onClick = onApprove,
enabled = code.length == state.codeLength && !state.isApproving
) {
Text("Approve")
}
OutlinedButton(onClick = onDeny) {
Text("Deny")
}
}
}
}
}
@Composable
private fun CodeInputRow(code: String, length: Int, onCodeChanged: (String) -> Unit) {
BasicTextField(
value = code,
onValueChange = { value ->
val filtered = value.filter { it.isLetterOrDigit() }.uppercase()
onCodeChanged(filtered)
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
decorationBox = {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
// First row (4 boxes)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
for (i in 0 until length / 2) {
CodeBox(char = code.getOrNull(i))
}
}
Spacer(modifier = Modifier.height(8.dp))
// Second row (4 boxes)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
for (i in length / 2 until length) {
CodeBox(char = code.getOrNull(i))
}
}
}
}
)
}
@Composable
private fun CodeBox(char: Char?) {
Box(
modifier = Modifier
.size(48.dp)
.border(
width = 2.dp,
color = if (char != null) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outline
},
shape = RoundedCornerShape(8.dp)
),
contentAlignment = Alignment.Center
) {
Text(
text = char?.toString() ?: "",
style = MaterialTheme.typography.headlineSmall
)
}
}
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun DeviceCodeApprovalEmptyPreview() {
RouseContextTheme(dynamicColor = false) {
DeviceCodeApprovalScreen()
}
}
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun DeviceCodeApprovalFilledPreview() {
RouseContextTheme(dynamicColor = false) {
DeviceCodeApprovalScreen(
state = DeviceCodeApprovalState(enteredCode = "ABCD1234")
)
}
}

View file

@ -0,0 +1,131 @@
package com.rousecontext.app.ui.screens
import androidx.compose.foundation.layout.Column
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.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
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.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.rousecontext.app.ui.theme.RouseContextTheme
@Immutable
data class HealthPermission(
val name: String,
val granted: Boolean
)
@Immutable
data class HealthConnectSettingsState(
val permissions: List<HealthPermission> = emptyList()
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HealthConnectSettingsScreen(
state: HealthConnectSettingsState = HealthConnectSettingsState(),
onGrantPermission: (String) -> Unit = {},
onBack: () -> Unit = {}
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Health Connect Settings") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Permissions",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(8.dp))
Card(modifier = Modifier.fillMaxWidth()) {
Column {
state.permissions.forEachIndexed { index, permission ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = permission.granted,
onCheckedChange = null,
enabled = false
)
Text(
text = permission.name,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.weight(1f)
.padding(start = 8.dp)
)
if (!permission.granted) {
TextButton(
onClick = { onGrantPermission(permission.name) }
) {
Text("Grant")
}
}
}
if (index < state.permissions.lastIndex) {
HorizontalDivider()
}
}
}
}
}
}
}
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun HealthConnectSettingsPreview() {
RouseContextTheme(dynamicColor = false) {
HealthConnectSettingsScreen(
state = HealthConnectSettingsState(
permissions = listOf(
HealthPermission("Steps", granted = true),
HealthPermission("Heart rate", granted = true),
HealthPermission("Sleep", granted = true),
HealthPermission("Workout history", granted = false),
HealthPermission("HRV", granted = false)
)
)
)
}
}

View file

@ -0,0 +1,122 @@
package com.rousecontext.app.ui.screens
import androidx.compose.foundation.layout.Column
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.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
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.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.rousecontext.app.ui.theme.RouseContextTheme
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HealthConnectSetupScreen(onGrantAccess: () -> Unit = {}, onCancel: () -> Unit = {}) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Health Connect Setup") },
navigationIcon = {
IconButton(onClick = onCancel) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 24.dp)
) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Health Connect lets AI clients read your health data " +
"to give personalized responses.",
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "We'll request access to:",
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.height(12.dp))
val permissions = listOf(
"Daily step count",
"Heart rate readings",
"Sleep sessions"
)
permissions.forEach { permission ->
Text(
text = "\u2022 $permission",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(start = 8.dp, bottom = 4.dp)
)
}
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "You can change these permissions at any time in system settings.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Every access is logged in the app's audit history.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.weight(1f))
Button(
onClick = onGrantAccess,
modifier = Modifier.fillMaxWidth()
) {
Text("Grant Health Access")
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = onCancel,
modifier = Modifier.fillMaxWidth()
) {
Text("Cancel")
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun HealthConnectSetupPreview() {
RouseContextTheme(dynamicColor = false) {
HealthConnectSetupScreen()
}
}

View file

@ -0,0 +1,153 @@
package com.rousecontext.app.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.ContentCopy
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.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.rousecontext.app.ui.theme.RouseContextTheme
@Immutable
data class IntegrationEnabledState(
val integrationName: String = "Health Connect",
val url: String = "https://brave-falcon.rousecontext.com/health"
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun IntegrationEnabledScreen(
state: IntegrationEnabledState = IntegrationEnabledState(),
onCopyUrl: () -> Unit = {},
onCancel: () -> Unit = {}
) {
Scaffold(
topBar = {
TopAppBar(title = { Text("${state.integrationName} Ready") })
}
) { padding ->
Surface(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(32.dp))
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "${state.integrationName} is set up",
style = MaterialTheme.typography.headlineSmall
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Add this URL to your AI client:",
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.height(12.dp))
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = state.url,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
IconButton(onClick = onCopyUrl) {
Icon(Icons.Default.ContentCopy, contentDescription = "Copy URL")
}
}
}
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "The first time your AI client connects, " +
"you'll be asked to approve it with a code.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Waiting for connection...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.weight(1f))
TextButton(onClick = onCancel) {
Text("Cancel")
}
}
}
}
}
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun IntegrationEnabledPreview() {
RouseContextTheme(dynamicColor = false) {
IntegrationEnabledScreen()
}
}

View file

@ -0,0 +1,279 @@
package com.rousecontext.app.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
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.Immutable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.rousecontext.app.ui.theme.RouseContextTheme
@Immutable
data class AuthorizedClient(
val name: String,
val authorizedDate: String,
val lastUsed: String
)
@Immutable
data class IntegrationManageState(
val integrationName: String = "Health Connect",
val status: IntegrationStatus = IntegrationStatus.ACTIVE,
val url: String = "https://brave-falcon.rousecontext.com/health",
val recentActivity: List<AuditEntry> = emptyList(),
val authorizedClients: List<AuthorizedClient> = emptyList()
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun IntegrationManageScreen(
state: IntegrationManageState = IntegrationManageState(),
onCopyUrl: () -> Unit = {},
onViewAllActivity: () -> Unit = {},
onRevokeClient: (String) -> Unit = {},
onSettings: () -> Unit = {},
onDisable: () -> Unit = {},
onBack: () -> Unit = {}
) {
Scaffold(
topBar = {
TopAppBar(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(state.integrationName)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = when (state.status) {
IntegrationStatus.ACTIVE -> "Active"
IntegrationStatus.PENDING -> "Pending"
},
style = MaterialTheme.typography.labelMedium,
color = when (state.status) {
IntegrationStatus.ACTIVE -> MaterialTheme.colorScheme.primary
IntegrationStatus.PENDING -> MaterialTheme.colorScheme.tertiary
}
)
}
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp)
) {
// URL card
item {
Spacer(modifier = Modifier.height(8.dp))
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = state.url,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
IconButton(onClick = onCopyUrl) {
Icon(Icons.Default.ContentCopy, contentDescription = "Copy URL")
}
}
}
Spacer(modifier = Modifier.height(24.dp))
}
// Pending waiting message or recent activity
if (state.status == IntegrationStatus.PENDING) {
item {
Text(
text = "Waiting for first client...",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Add the URL above to your AI client to get started.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
}
} else {
item {
Text(
text = "Recent Activity",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(8.dp))
}
items(state.recentActivity) { entry ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(entry.time, style = MaterialTheme.typography.bodySmall)
Text(entry.toolName, style = MaterialTheme.typography.bodySmall)
Text("${entry.durationMs}ms", style = MaterialTheme.typography.bodySmall)
}
}
if (state.recentActivity.isNotEmpty()) {
item {
TextButton(onClick = onViewAllActivity) {
Text("View all")
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
// Authorized clients
item {
Text(
text = "Authorized Clients",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(8.dp))
}
if (state.authorizedClients.isEmpty()) {
item {
Text(
text = "(none yet)",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
}
} else {
item {
Card(modifier = Modifier.fillMaxWidth()) {
Column {
state.authorizedClients.forEachIndexed { index, client ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
client.name,
style = MaterialTheme.typography.bodyLarge
)
Text(
"${client.authorizedDate} \u00B7 ${client.lastUsed}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
TextButton(onClick = { onRevokeClient(client.name) }) {
Text("Revoke")
}
}
if (index < state.authorizedClients.lastIndex) {
HorizontalDivider()
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
}
}
// Action buttons
item {
Button(
onClick = onSettings,
modifier = Modifier.fillMaxWidth()
) {
Text("Settings")
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = onDisable,
modifier = Modifier.fillMaxWidth()
) {
Text("Disable Integration")
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
}
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun IntegrationManageActivePreview() {
RouseContextTheme(dynamicColor = false) {
IntegrationManageScreen(
state = IntegrationManageState(
status = IntegrationStatus.ACTIVE,
recentActivity = listOf(
AuditEntry("10:32 AM", "get_steps", 142),
AuditEntry("10:31 AM", "get_sleep", 89)
),
authorizedClients = listOf(
AuthorizedClient("Claude Desktop", "Apr 2", "2 hours ago"),
AuthorizedClient("Cursor", "Apr 3", "just now")
)
)
)
}
}
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun IntegrationManagePendingPreview() {
RouseContextTheme(dynamicColor = false) {
IntegrationManageScreen(
state = IntegrationManageState(
status = IntegrationStatus.PENDING
)
)
}
}

View file

@ -0,0 +1,522 @@
package com.rousecontext.app.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.OutlinedButton
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.Immutable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.rousecontext.app.ui.theme.RouseContextTheme
enum class ConnectionStatus { CONNECTED, DISCONNECTED }
enum class IntegrationStatus { ACTIVE, PENDING }
@Immutable
data class IntegrationItem(
val id: String,
val name: String,
val status: IntegrationStatus,
val url: String
)
@Immutable
data class AuditEntry(
val time: String,
val toolName: String,
val durationMs: Long
)
sealed interface CertBanner {
data object Renewing : CertBanner
data class Expired(val renewalInProgress: Boolean) : CertBanner
data class RateLimited(val retryDate: String) : CertBanner
data class Onboarding(
val generatingKeysDone: Boolean,
val registeringDone: Boolean,
val issuingCert: Boolean
) : CertBanner
}
@Immutable
data class DashboardState(
val connectionStatus: ConnectionStatus = ConnectionStatus.DISCONNECTED,
val activeSessionCount: Int = 0,
val integrations: List<IntegrationItem> = emptyList(),
val recentActivity: List<AuditEntry> = emptyList(),
val certBanner: CertBanner? = null,
val hasMoreIntegrationsToAdd: Boolean = true
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainDashboardScreen(
state: DashboardState = DashboardState(),
selectedTab: Int = 0,
onAddIntegration: () -> Unit = {},
onIntegrationClick: (String) -> Unit = {},
onCopyUrl: (String) -> Unit = {},
onViewAllActivity: () -> Unit = {},
onRetryRenewal: () -> Unit = {},
onTabSelected: (Int) -> Unit = {}
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Rouse Context") }
)
},
bottomBar = {
NavigationBar {
NavigationBarItem(
selected = selectedTab == 0,
onClick = { onTabSelected(0) },
icon = { Icon(Icons.Default.Home, contentDescription = "Home") },
label = { Text("Home") }
)
NavigationBarItem(
selected = selectedTab == 1,
onClick = { onTabSelected(1) },
icon = { Icon(Icons.Default.History, contentDescription = "Audit") },
label = { Text("Audit") }
)
NavigationBarItem(
selected = selectedTab == 2,
onClick = { onTabSelected(2) },
icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
label = { Text("Settings") }
)
}
}
) { padding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp)
) {
// Connection status
item {
ConnectionStatusRow(state.connectionStatus, state.activeSessionCount)
Spacer(modifier = Modifier.height(16.dp))
}
// Cert banner
state.certBanner?.let { banner ->
item {
CertBannerCard(banner, onRetryRenewal)
Spacer(modifier = Modifier.height(16.dp))
}
}
// Integrations header
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Integrations",
style = MaterialTheme.typography.titleMedium
)
if (state.integrations.isNotEmpty() && state.hasMoreIntegrationsToAdd) {
IconButton(onClick = onAddIntegration) {
Icon(Icons.Default.Add, contentDescription = "Add integration")
}
}
}
}
// Empty state or integration list
if (state.integrations.isEmpty()) {
item {
EmptyIntegrationsCard(onAddIntegration)
}
} else {
item {
Card(modifier = Modifier.fillMaxWidth()) {
Column {
state.integrations.forEachIndexed { index, integration ->
IntegrationRow(
integration = integration,
onClick = { onIntegrationClick(integration.id) },
onCopy = { onCopyUrl(integration.url) }
)
if (index < state.integrations.lastIndex) {
HorizontalDivider()
}
}
}
}
}
}
// Recent activity
item {
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Recent Activity",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(8.dp))
}
if (state.recentActivity.isEmpty()) {
item {
Text(
text = "(empty)",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
items(state.recentActivity) { entry ->
ActivityRow(entry)
}
item {
TextButton(onClick = onViewAllActivity) {
Text("View all")
}
}
}
}
}
}
@Composable
private fun ConnectionStatusRow(status: ConnectionStatus, sessionCount: Int) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.Circle,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = when (status) {
ConnectionStatus.CONNECTED -> MaterialTheme.colorScheme.primary
ConnectionStatus.DISCONNECTED -> MaterialTheme.colorScheme.outline
}
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = when (status) {
ConnectionStatus.CONNECTED ->
"Connected ($sessionCount active session${
if (sessionCount != 1) "s" else ""
})"
ConnectionStatus.DISCONNECTED -> "Disconnected"
},
style = MaterialTheme.typography.bodyMedium
)
}
}
@Composable
private fun CertBannerCard(banner: CertBanner, onRetry: () -> Unit) {
val containerColor = when (banner) {
is CertBanner.Expired -> if (!banner.renewalInProgress) {
MaterialTheme.colorScheme.errorContainer
} else {
MaterialTheme.colorScheme.secondaryContainer
}
is CertBanner.RateLimited -> MaterialTheme.colorScheme.tertiaryContainer
is CertBanner.Renewing -> MaterialTheme.colorScheme.secondaryContainer
is CertBanner.Onboarding -> MaterialTheme.colorScheme.secondaryContainer
}
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = containerColor)
) {
Column(modifier = Modifier.padding(16.dp)) {
when (banner) {
is CertBanner.Renewing -> {
Text(
"Refreshing your certificate...",
style = MaterialTheme.typography.bodyMedium
)
Text(
"Integrations may be briefly unavailable.",
style = MaterialTheme.typography.bodySmall
)
}
is CertBanner.Expired -> if (banner.renewalInProgress) {
Text(
"Certificate expired. Renewing...",
style = MaterialTheme.typography.bodyMedium
)
Text(
"Integrations are offline until this completes.",
style = MaterialTheme.typography.bodySmall
)
} else {
Text(
"Certificate expired. Renewal failed — check your connection.",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(onClick = onRetry) { Text("Retry") }
}
is CertBanner.RateLimited -> {
Text(
"Certificate issuance delayed.",
style = MaterialTheme.typography.bodyMedium
)
Text(
"Will retry automatically on ${banner.retryDate}.",
style = MaterialTheme.typography.bodySmall
)
}
is CertBanner.Onboarding -> {
Text("Setting up your device...", style = MaterialTheme.typography.bodyMedium)
Spacer(modifier = Modifier.height(4.dp))
val checkMark = "\u2713"
val dots = "..."
val genStatus = if (banner.generatingKeysDone) checkMark else dots
Text(
text = "Generating keys $genStatus",
style = MaterialTheme.typography.bodySmall
)
Text(
text = "Registering ${if (banner.registeringDone) checkMark else dots}",
style = MaterialTheme.typography.bodySmall
)
Text(
text = "Issuing certificate${if (banner.issuingCert) dots else ""}",
style = MaterialTheme.typography.bodySmall
)
}
}
}
}
}
@Composable
private fun EmptyIntegrationsCard(onAdd: () -> Unit) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "No integrations enabled yet.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
Button(onClick = onAdd) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Add your first")
}
}
}
}
@Composable
private fun IntegrationRow(integration: IntegrationItem, onClick: () -> Unit, onCopy: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
Icon(
imageVector = Icons.Default.Circle,
contentDescription = null,
modifier = Modifier.size(10.dp),
tint = when (integration.status) {
IntegrationStatus.ACTIVE -> MaterialTheme.colorScheme.primary
IntegrationStatus.PENDING -> MaterialTheme.colorScheme.tertiary
}
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(integration.name, style = MaterialTheme.typography.bodyLarge)
if (integration.status == IntegrationStatus.PENDING) {
Text(
"Waiting for client",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = when (integration.status) {
IntegrationStatus.ACTIVE -> "Active"
IntegrationStatus.PENDING -> "Pending"
},
style = MaterialTheme.typography.labelMedium,
color = when (integration.status) {
IntegrationStatus.ACTIVE -> MaterialTheme.colorScheme.primary
IntegrationStatus.PENDING -> MaterialTheme.colorScheme.tertiary
}
)
Spacer(modifier = Modifier.width(8.dp))
TextButton(onClick = onCopy) {
Text("Copy")
}
}
}
}
@Composable
private fun ActivityRow(entry: AuditEntry) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(entry.time, style = MaterialTheme.typography.bodySmall)
Text(entry.toolName, style = MaterialTheme.typography.bodySmall)
Text("${entry.durationMs}ms", style = MaterialTheme.typography.bodySmall)
}
}
// --- Previews ---
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun DashboardEmptyPreview() {
RouseContextTheme(dynamicColor = false) {
MainDashboardScreen(state = DashboardState())
}
}
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun DashboardWithIntegrationsPreview() {
RouseContextTheme(dynamicColor = false) {
MainDashboardScreen(
state = DashboardState(
connectionStatus = ConnectionStatus.CONNECTED,
activeSessionCount = 1,
integrations = listOf(
IntegrationItem(
id = "health",
name = "Health Connect",
status = IntegrationStatus.ACTIVE,
url = "https://brave-falcon.rousecontext.com/health"
),
IntegrationItem(
id = "notifications",
name = "Notifications",
status = IntegrationStatus.PENDING,
url = "https://brave-falcon.rousecontext.com/notifications"
)
),
recentActivity = listOf(
AuditEntry("10:32 AM", "health/get_steps", 142),
AuditEntry("10:31 AM", "health/get_sleep", 89)
)
)
)
}
}
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun DashboardCertExpiredPreview() {
RouseContextTheme(dynamicColor = false) {
MainDashboardScreen(
state = DashboardState(
certBanner = CertBanner.Expired(renewalInProgress = false),
integrations = listOf(
IntegrationItem(
id = "health",
name = "Health Connect",
status = IntegrationStatus.ACTIVE,
url = "https://brave-falcon.rousecontext.com/health"
)
)
)
)
}
}
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun DashboardCertRenewingPreview() {
RouseContextTheme(dynamicColor = false) {
MainDashboardScreen(
state = DashboardState(
certBanner = CertBanner.Renewing,
integrations = listOf(
IntegrationItem(
id = "health",
name = "Health Connect",
status = IntegrationStatus.ACTIVE,
url = "https://brave-falcon.rousecontext.com/health"
)
)
)
)
}
}
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun DashboardCertRateLimitedPreview() {
RouseContextTheme(dynamicColor = false) {
MainDashboardScreen(
state = DashboardState(
certBanner = CertBanner.RateLimited(retryDate = "Apr 11")
)
)
}
}
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun DashboardCertOnboardingPreview() {
RouseContextTheme(dynamicColor = false) {
MainDashboardScreen(
state = DashboardState(
certBanner = CertBanner.Onboarding(
generatingKeysDone = true,
registeringDone = true,
issuingCert = true
)
)
)
}
}

View file

@ -0,0 +1,171 @@
package com.rousecontext.app.ui.screens
import androidx.compose.foundation.layout.Column
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.width
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
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.semantics.Role
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.rousecontext.app.ui.theme.RouseContextTheme
enum class NotificationMode {
SUMMARY,
EACH_USAGE,
SUPPRESS
}
@Immutable
data class NotificationPreferencesState(
val selectedMode: NotificationMode = NotificationMode.SUMMARY
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NotificationPreferencesScreen(
state: NotificationPreferencesState = NotificationPreferencesState(),
onModeSelected: (NotificationMode) -> Unit = {},
onContinue: () -> Unit = {},
onBack: () -> Unit = {}
) {
var selected by remember(state.selectedMode) { mutableStateOf(state.selectedMode) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Notification Preferences") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 24.dp)
) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "How would you like to be notified after AI sessions?",
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.height(24.dp))
Column(modifier = Modifier.selectableGroup()) {
NotificationOption(
title = "Summary (recommended)",
description = "\"10 tool usages made\"",
selected = selected == NotificationMode.SUMMARY,
onClick = {
selected = NotificationMode.SUMMARY
onModeSelected(NotificationMode.SUMMARY)
}
)
Spacer(modifier = Modifier.height(16.dp))
NotificationOption(
title = "Each tool usage",
description = "Individual notification per data access",
selected = selected == NotificationMode.EACH_USAGE,
onClick = {
selected = NotificationMode.EACH_USAGE
onModeSelected(NotificationMode.EACH_USAGE)
}
)
Spacer(modifier = Modifier.height(16.dp))
NotificationOption(
title = "Suppress notifications",
description = "Only see activity in audit log",
selected = selected == NotificationMode.SUPPRESS,
onClick = {
selected = NotificationMode.SUPPRESS
onModeSelected(NotificationMode.SUPPRESS)
}
)
}
Spacer(modifier = Modifier.weight(1f))
Button(
onClick = onContinue,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp)
) {
Text("Continue")
}
}
}
}
@Composable
private fun NotificationOption(
title: String,
description: String,
selected: Boolean,
onClick: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = selected,
onClick = onClick,
role = Role.RadioButton
)
.padding(vertical = 8.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = selected, onClick = null)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(title, style = MaterialTheme.typography.bodyLarge)
Text(
description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun NotificationPreferencesPreview() {
RouseContextTheme(dynamicColor = false) {
NotificationPreferencesScreen()
}
}

View file

@ -0,0 +1,154 @@
package com.rousecontext.app.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.HourglassEmpty
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.rousecontext.app.ui.theme.RouseContextTheme
sealed interface SettingUpVariant {
data object FirstTime : SettingUpVariant
data object Refreshing : SettingUpVariant
data class RateLimited(val expectedDate: String) : SettingUpVariant
}
@Immutable
data class SettingUpState(
val variant: SettingUpVariant = SettingUpVariant.FirstTime
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingUpScreen(state: SettingUpState = SettingUpState(), onCancel: () -> Unit = {}) {
Scaffold(
topBar = {
TopAppBar(title = { Text("Setting Up") })
}
) { padding ->
Surface(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
when (state.variant) {
is SettingUpVariant.FirstTime, is SettingUpVariant.Refreshing -> {
CircularProgressIndicator(modifier = Modifier.size(48.dp))
}
is SettingUpVariant.RateLimited -> {
Icon(
imageVector = Icons.Default.HourglassEmpty,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.tertiary
)
}
}
Spacer(modifier = Modifier.height(32.dp))
Text(
text = when (state.variant) {
is SettingUpVariant.FirstTime ->
"We're issuing a secure certificate for your device."
is SettingUpVariant.Refreshing ->
"Your certificate is being refreshed. " +
"This usually takes about 30 seconds."
is SettingUpVariant.RateLimited ->
"Certificate issuance is temporarily delayed."
},
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(8.dp))
when (state.variant) {
is SettingUpVariant.FirstTime -> {
Text(
text = "This usually takes about 30 seconds.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
is SettingUpVariant.Refreshing -> { /* no extra text */ }
is SettingUpVariant.RateLimited -> {
Text(
text = "Expected: ${state.variant.expectedDate}.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "We'll notify you when it's ready.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(32.dp))
TextButton(onClick = onCancel) {
Text("Cancel")
}
}
}
}
}
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun SettingUpFirstTimePreview() {
RouseContextTheme(dynamicColor = false) {
SettingUpScreen(state = SettingUpState(SettingUpVariant.FirstTime))
}
}
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun SettingUpRefreshingPreview() {
RouseContextTheme(dynamicColor = false) {
SettingUpScreen(state = SettingUpState(SettingUpVariant.Refreshing))
}
}
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun SettingUpRateLimitedPreview() {
RouseContextTheme(dynamicColor = false) {
SettingUpScreen(state = SettingUpState(SettingUpVariant.RateLimited("Apr 11")))
}
}

View file

@ -0,0 +1,324 @@
package com.rousecontext.app.ui.screens
import androidx.compose.foundation.layout.Column
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.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.rousecontext.app.ui.theme.RouseContextTheme
@Immutable
data class SettingsState(
val idleTimeoutMinutes: Int = 5,
val idleTimeoutDisabled: Boolean = false,
val batteryOptimizationExempt: Boolean = false,
val postSessionMode: String = "Summary",
val canRotateAddress: Boolean = true,
val rotationCooldownMessage: String? = null,
val showBatteryWarning: Boolean = true,
val versionName: String = "0.1.0"
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
state: SettingsState = SettingsState(),
onIdleTimeoutChanged: (Int) -> Unit = {},
onDisableTimeoutToggled: (Boolean) -> Unit = {},
onPostSessionModeChanged: (String) -> Unit = {},
onGenerateNewAddress: () -> Unit = {},
onFixBatteryOptimization: () -> Unit = {},
onTabSelected: (Int) -> Unit = {}
) {
Scaffold(
topBar = {
TopAppBar(title = { Text("Settings") })
},
bottomBar = {
NavigationBar {
NavigationBarItem(
selected = false,
onClick = { onTabSelected(0) },
icon = { Icon(Icons.Default.Home, contentDescription = "Home") },
label = { Text("Home") }
)
NavigationBarItem(
selected = false,
onClick = { onTabSelected(1) },
icon = { Icon(Icons.Default.History, contentDescription = "Audit") },
label = { Text("Audit") }
)
NavigationBarItem(
selected = true,
onClick = { onTabSelected(2) },
icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
label = { Text("Settings") }
)
}
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(8.dp))
// Connection section
SectionHeader("Connection")
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
SettingsDropdown(
label = "Idle timeout",
selected = "${state.idleTimeoutMinutes} min",
options = listOf("2 min", "5 min", "10 min"),
onSelected = { value ->
val minutes = value.replace(" min", "").toIntOrNull() ?: 5
onIdleTimeoutChanged(minutes)
}
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text("Disable timeout", style = MaterialTheme.typography.bodyLarge)
if (!state.batteryOptimizationExempt) {
Text(
"(requires battery exemption)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Switch(
checked = state.idleTimeoutDisabled,
onCheckedChange = onDisableTimeoutToggled,
enabled = state.batteryOptimizationExempt
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// Notifications section
SectionHeader("Notifications")
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
SettingsDropdown(
label = "After session",
selected = state.postSessionMode,
options = listOf("Summary", "Each usage", "Suppress"),
onSelected = onPostSessionModeChanged
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Security section
SectionHeader("Security")
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text("Generate new address", style = MaterialTheme.typography.bodyLarge)
Text(
text = state.rotationCooldownMessage ?: "(once per 30 days)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
TextButton(
onClick = onGenerateNewAddress,
enabled = state.canRotateAddress
) {
Text("Rotate")
}
}
}
}
// Battery warning
if (state.showBatteryWarning) {
Spacer(modifier = Modifier.height(16.dp))
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
"Battery optimization",
style = MaterialTheme.typography.bodyLarge
)
Text(
"Disable to ensure reliable wake-ups.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
OutlinedButton(onClick = onFixBatteryOptimization) {
Text("Fix this")
}
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// About section
SectionHeader("About")
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
"Version ${state.versionName}",
style = MaterialTheme.typography.bodyMedium
)
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text("Apache 2.0 License", style = MaterialTheme.typography.bodyMedium)
}
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
@Composable
private fun SectionHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(vertical = 8.dp)
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SettingsDropdown(
label: String,
selected: String,
options: List<String>,
onSelected: (String) -> Unit
) {
var expanded by remember { mutableStateOf(false) }
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
)
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it }
) {
TextField(
value = selected,
onValueChange = {},
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
modifier = Modifier
.width(150.dp)
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
options.forEach { option ->
DropdownMenuItem(
text = { Text(option) },
onClick = {
onSelected(option)
expanded = false
}
)
}
}
}
}
}
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun SettingsPreview() {
RouseContextTheme(dynamicColor = false) {
SettingsScreen()
}
}
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun SettingsNoBatteryWarningPreview() {
RouseContextTheme(dynamicColor = false) {
SettingsScreen(
state = SettingsState(
showBatteryWarning = false,
batteryOptimizationExempt = true
)
)
}
}

View file

@ -0,0 +1,79 @@
package com.rousecontext.app.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.material.icons.Icons
import androidx.compose.material.icons.filled.Hub
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.rousecontext.app.ui.theme.RouseContextTheme
@Composable
fun WelcomeScreen(onGetStarted: () -> Unit = {}) {
Surface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Default.Hub,
contentDescription = "Rouse Context",
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(32.dp))
Text(
text = "Rouse Context",
style = MaterialTheme.typography.headlineLarge
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Turn your phone into a secure AI context server. " +
"Your data stays on your device — AI clients connect " +
"through an encrypted tunnel.",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(48.dp))
Button(
onClick = onGetStarted,
modifier = Modifier.fillMaxWidth()
) {
Text("Get Started")
}
}
}
}
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun WelcomeScreenPreview() {
RouseContextTheme(dynamicColor = false) {
WelcomeScreen()
}
}

View file

@ -0,0 +1,35 @@
package com.rousecontext.app.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val LightColorScheme = lightColorScheme()
private val DarkColorScheme = darkColorScheme()
@Composable
fun RouseContextTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
content = content
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 B

View file

@ -0,0 +1,398 @@
package com.rousecontext.app.ui.screenshots
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import com.rousecontext.app.ui.screens.AddIntegrationPickerScreen
import com.rousecontext.app.ui.screens.AuditEntry
import com.rousecontext.app.ui.screens.AuditHistoryEntry
import com.rousecontext.app.ui.screens.AuditHistoryGroup
import com.rousecontext.app.ui.screens.AuditHistoryScreen
import com.rousecontext.app.ui.screens.AuditHistoryState
import com.rousecontext.app.ui.screens.AuthorizedClient
import com.rousecontext.app.ui.screens.CertBanner
import com.rousecontext.app.ui.screens.ConnectionConfirmedScreen
import com.rousecontext.app.ui.screens.ConnectionStatus
import com.rousecontext.app.ui.screens.DashboardState
import com.rousecontext.app.ui.screens.DeviceCodeApprovalScreen
import com.rousecontext.app.ui.screens.DeviceCodeApprovalState
import com.rousecontext.app.ui.screens.HealthConnectSettingsScreen
import com.rousecontext.app.ui.screens.HealthConnectSettingsState
import com.rousecontext.app.ui.screens.HealthConnectSetupScreen
import com.rousecontext.app.ui.screens.HealthPermission
import com.rousecontext.app.ui.screens.IntegrationEnabledScreen
import com.rousecontext.app.ui.screens.IntegrationEnabledState
import com.rousecontext.app.ui.screens.IntegrationItem
import com.rousecontext.app.ui.screens.IntegrationManageScreen
import com.rousecontext.app.ui.screens.IntegrationManageState
import com.rousecontext.app.ui.screens.IntegrationStatus
import com.rousecontext.app.ui.screens.MainDashboardScreen
import com.rousecontext.app.ui.screens.NotificationPreferencesScreen
import com.rousecontext.app.ui.screens.PickerIntegration
import com.rousecontext.app.ui.screens.PickerIntegrationState
import com.rousecontext.app.ui.screens.SettingUpScreen
import com.rousecontext.app.ui.screens.SettingUpState
import com.rousecontext.app.ui.screens.SettingUpVariant
import com.rousecontext.app.ui.screens.SettingsScreen
import com.rousecontext.app.ui.screens.SettingsState
import com.rousecontext.app.ui.screens.WelcomeScreen
import com.rousecontext.app.ui.theme.RouseContextTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [33], qualifiers = "w400dp-h800dp-xxhdpi")
class ScreenScreenshotTest {
@get:Rule
val composeRule = createComposeRule()
private fun capture(name: String, content: @Composable () -> Unit) {
composeRule.setContent {
RouseContextTheme(dynamicColor = false) {
content()
}
}
composeRule.onRoot().captureRoboImage("screenshots/$name.png")
}
// 1. Welcome
@Test
fun welcome() = capture("01_welcome") {
WelcomeScreen()
}
// 2. Dashboard - Empty
@Test
fun dashboardEmpty() = capture("02_dashboard_empty") {
MainDashboardScreen(state = DashboardState())
}
// 2. Dashboard - With integrations
@Test
fun dashboardWithIntegrations() = capture("03_dashboard_with_integrations") {
MainDashboardScreen(
state = DashboardState(
connectionStatus = ConnectionStatus.CONNECTED,
activeSessionCount = 1,
integrations = listOf(
IntegrationItem(
id = "health",
name = "Health Connect",
status = IntegrationStatus.ACTIVE,
url = "https://brave-falcon.rousecontext.com/health"
),
IntegrationItem(
id = "notifications",
name = "Notifications",
status = IntegrationStatus.PENDING,
url = "https://brave-falcon.rousecontext.com/notifications"
)
),
recentActivity = listOf(
AuditEntry("10:32 AM", "health/get_steps", 142),
AuditEntry("10:31 AM", "health/get_sleep", 89)
)
)
)
}
// 2. Dashboard - Cert renewing
@Test
fun dashboardCertRenewing() = capture("04_dashboard_cert_renewing") {
MainDashboardScreen(
state = DashboardState(
certBanner = CertBanner.Renewing,
integrations = listOf(
IntegrationItem(
"health",
"Health Connect",
IntegrationStatus.ACTIVE,
"https://brave-falcon.rousecontext.com/health"
)
)
)
)
}
// 2. Dashboard - Cert expired (renewal failing)
@Test
fun dashboardCertExpiredFailing() = capture("05_dashboard_cert_expired_failing") {
MainDashboardScreen(
state = DashboardState(
certBanner = CertBanner.Expired(renewalInProgress = false),
integrations = listOf(
IntegrationItem(
"health",
"Health Connect",
IntegrationStatus.ACTIVE,
"https://brave-falcon.rousecontext.com/health"
)
)
)
)
}
// 2. Dashboard - Cert expired (renewing)
@Test
fun dashboardCertExpiredRenewing() = capture("06_dashboard_cert_expired_renewing") {
MainDashboardScreen(
state = DashboardState(
certBanner = CertBanner.Expired(renewalInProgress = true),
integrations = listOf(
IntegrationItem(
"health",
"Health Connect",
IntegrationStatus.ACTIVE,
"https://brave-falcon.rousecontext.com/health"
)
)
)
)
}
// 2. Dashboard - Rate limited
@Test
fun dashboardCertRateLimited() = capture("07_dashboard_cert_rate_limited") {
MainDashboardScreen(
state = DashboardState(certBanner = CertBanner.RateLimited(retryDate = "Apr 11"))
)
}
// 2. Dashboard - Onboarding
@Test
fun dashboardCertOnboarding() = capture("08_dashboard_cert_onboarding") {
MainDashboardScreen(
state = DashboardState(
certBanner = CertBanner.Onboarding(
generatingKeysDone = true,
registeringDone = true,
issuingCert = true
)
)
)
}
// 3. Add Integration Picker
@Test
fun addIntegrationPicker() = capture("09_add_integration_picker") {
AddIntegrationPickerScreen(
integrations = listOf(
PickerIntegration(
"health",
"Health Connect",
"Share step count, heart rate, and sleep data with AI clients",
PickerIntegrationState.AVAILABLE
),
PickerIntegration(
"notifications",
"Notifications",
"Let AI clients read your notifications",
PickerIntegrationState.UNAVAILABLE
)
)
)
}
// 3. Add Integration Picker - with disabled
@Test
fun addIntegrationPickerWithDisabled() = capture("10_add_integration_picker_disabled") {
AddIntegrationPickerScreen(
integrations = listOf(
PickerIntegration(
"health",
"Health Connect",
"Share step count, heart rate, and sleep data with AI clients",
PickerIntegrationState.DISABLED
),
PickerIntegration(
"notifications",
"Notifications",
"Let AI clients read your notifications",
PickerIntegrationState.UNAVAILABLE
)
)
)
}
// 4. Notification Preferences
@Test
fun notificationPreferences() = capture("11_notification_preferences") {
NotificationPreferencesScreen()
}
// 5. Setting Up - First time
@Test
fun settingUpFirstTime() = capture("12_setting_up_first_time") {
SettingUpScreen(state = SettingUpState(SettingUpVariant.FirstTime))
}
// 5. Setting Up - Refreshing
@Test
fun settingUpRefreshing() = capture("13_setting_up_refreshing") {
SettingUpScreen(state = SettingUpState(SettingUpVariant.Refreshing))
}
// 5. Setting Up - Rate limited
@Test
fun settingUpRateLimited() = capture("14_setting_up_rate_limited") {
SettingUpScreen(state = SettingUpState(SettingUpVariant.RateLimited("Apr 11")))
}
// 6. Integration Enabled
@Test
fun integrationEnabled() = capture("15_integration_enabled") {
IntegrationEnabledScreen(
state = IntegrationEnabledState(
integrationName = "Health Connect",
url = "https://brave-falcon.rousecontext.com/health"
)
)
}
// 7. Integration Manage - Active
@Test
fun integrationManageActive() = capture("16_integration_manage_active") {
IntegrationManageScreen(
state = IntegrationManageState(
status = IntegrationStatus.ACTIVE,
recentActivity = listOf(
AuditEntry("10:32 AM", "get_steps", 142),
AuditEntry("10:31 AM", "get_sleep", 89)
),
authorizedClients = listOf(
AuthorizedClient("Claude Desktop", "Apr 2", "2 hours ago"),
AuthorizedClient("Cursor", "Apr 3", "just now")
)
)
)
}
// 7. Integration Manage - Pending
@Test
fun integrationManagePending() = capture("17_integration_manage_pending") {
IntegrationManageScreen(
state = IntegrationManageState(status = IntegrationStatus.PENDING)
)
}
// 8. Device Code Approval - Empty
@Test
fun deviceCodeApprovalEmpty() = capture("18_device_code_approval_empty") {
DeviceCodeApprovalScreen()
}
// 8. Device Code Approval - Filled
@Test
fun deviceCodeApprovalFilled() = capture("19_device_code_approval_filled") {
DeviceCodeApprovalScreen(
state = DeviceCodeApprovalState(enteredCode = "ABCD1234")
)
}
// 9. Connection Confirmed
@Test
fun connectionConfirmed() = capture("20_connection_confirmed") {
ConnectionConfirmedScreen()
}
// 10. Audit History - Populated
@Test
fun auditHistoryPopulated() = capture("21_audit_history_populated") {
AuditHistoryScreen(
state = AuditHistoryState(
groups = listOf(
AuditHistoryGroup(
"Today",
listOf(
AuditHistoryEntry("10:32 AM", "health/get_steps", 142, "{days: 7}"),
AuditHistoryEntry("10:31 AM", "health/get_sleep", 89, "{days: 1}"),
AuditHistoryEntry(
"10:31 AM",
"health/get_heart_rate",
201,
"{days: 7}"
)
)
),
AuditHistoryGroup(
"Yesterday",
listOf(
AuditHistoryEntry("3:15 PM", "health/get_steps", 156, "{days: 30}")
)
)
)
)
)
}
// 10. Audit History - Empty
@Test
fun auditHistoryEmpty() = capture("22_audit_history_empty") {
AuditHistoryScreen()
}
// 10. Audit History - Filtered
@Test
fun auditHistoryFiltered() = capture("23_audit_history_filtered") {
AuditHistoryScreen(
state = AuditHistoryState(
providerFilter = "health",
dateFilter = "Last 7 days",
groups = listOf(
AuditHistoryGroup(
"Today",
listOf(
AuditHistoryEntry("10:32 AM", "health/get_steps", 142, "{days: 7}")
)
)
)
)
)
}
// 11. Settings
@Test
fun settings() = capture("24_settings") {
SettingsScreen()
}
// 11. Settings - No battery warning
@Test
fun settingsNoBatteryWarning() = capture("25_settings_no_battery_warning") {
SettingsScreen(
state = SettingsState(
showBatteryWarning = false,
batteryOptimizationExempt = true
)
)
}
// 12. Health Connect Setup
@Test
fun healthConnectSetup() = capture("26_health_connect_setup") {
HealthConnectSetupScreen()
}
// 13. Health Connect Settings
@Test
fun healthConnectSettings() = capture("27_health_connect_settings") {
HealthConnectSettingsScreen(
state = HealthConnectSettingsState(
permissions = listOf(
HealthPermission("Steps", granted = true),
HealthPermission("Heart rate", granted = true),
HealthPermission("Sleep", granted = true),
HealthPermission("Workout history", granted = false),
HealthPermission("HRV", granted = false)
)
)
)
}
}

View file

@ -0,0 +1 @@
sdk=33

View file

@ -3,8 +3,10 @@ plugins {
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.ktlint) apply false
alias(libs.plugins.detekt)
alias(libs.plugins.roborazzi) apply false
}
subprojects {

View file

@ -18,9 +18,11 @@ complexity:
active: true
LongMethod:
threshold: 60
ignoreAnnotated: ['Composable']
LongParameterList:
functionThreshold: 8
constructorThreshold: 10
ignoreAnnotated: ['Composable']
TooManyFunctions:
active: false
@ -42,8 +44,10 @@ style:
naming:
active: true
FunctionNaming:
# Allow backtick-quoted test names
ignoreAnnotated: ['Test']
# Allow backtick-quoted test names and Composable functions
ignoreAnnotated: ['Test', 'Composable']
MatchingDeclarationName:
active: false
potential-bugs:
active: true

View file

@ -16,6 +16,13 @@ junit = "4.13.2"
kotlinx-coroutines = "1.10.2"
ktlint = "12.2.0"
detekt = "1.23.7"
compose-bom = "2025.02.00"
compose-compiler = "1.5.15"
activity-compose = "1.10.1"
navigation-compose = "2.8.9"
roborazzi = "1.36.0"
robolectric = "4.14.1"
compose-ui-test = "1.7.8"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -34,10 +41,31 @@ kotlinx-io-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-io-core", v
junit = { group = "junit", name = "junit", version.ref = "junit" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
# Compose
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
compose-ui = { group = "androidx.compose.ui", name = "ui" }
compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" }
activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" }
navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation-compose" }
compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
# Roborazzi
roborazzi = { group = "io.github.takahirom.roborazzi", name = "roborazzi", version.ref = "roborazzi" }
roborazzi-compose = { group = "io.github.takahirom.roborazzi", name = "roborazzi-compose", version.ref = "roborazzi" }
roborazzi-junit-rule = { group = "io.github.takahirom.roborazzi", name = "roborazzi-junit-rule", version.ref = "roborazzi" }
robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" }