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>
|
|
@ -10,3 +10,4 @@ trim_trailing_whitespace = true
|
|||
|
||||
[*.{kt,kts}]
|
||||
ktlint_code_style = android_studio
|
||||
ktlint_standard_function-naming = disabled
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
BIN
app/screenshots/01_welcome.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
app/screenshots/02_dashboard_empty.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
app/screenshots/03_dashboard_with_integrations.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
app/screenshots/04_dashboard_cert_renewing.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
app/screenshots/05_dashboard_cert_expired_failing.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
app/screenshots/06_dashboard_cert_expired_renewing.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
app/screenshots/07_dashboard_cert_rate_limited.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
app/screenshots/08_dashboard_cert_onboarding.png
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
app/screenshots/09_add_integration_picker.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
app/screenshots/10_add_integration_picker_disabled.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
app/screenshots/11_notification_preferences.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
app/screenshots/12_setting_up_first_time.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
app/screenshots/13_setting_up_refreshing.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
app/screenshots/14_setting_up_rate_limited.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
app/screenshots/15_integration_enabled.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
app/screenshots/16_integration_manage_active.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
app/screenshots/17_integration_manage_pending.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
app/screenshots/18_device_code_approval_empty.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
app/screenshots/19_device_code_approval_filled.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
app/screenshots/20_connection_confirmed.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
app/screenshots/21_audit_history_populated.png
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
app/screenshots/22_audit_history_empty.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
app/screenshots/23_audit_history_filtered.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
app/screenshots/24_settings.png
Normal file
|
After Width: | Height: | Size: 119 KiB |
BIN
app/screenshots/25_settings_no_battery_warning.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
app/screenshots/26_health_connect_setup.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
app/screenshots/27_health_connect_settings.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
255
app/screenshots/dashboard.html
Normal 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
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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}")
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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")))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
35
app/src/main/java/com/rousecontext/app/ui/theme/Theme.kt
Normal 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
|
||||
)
|
||||
}
|
||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 123 B |
|
|
@ -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)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
1
app/src/test/resources/robolectric.properties
Normal file
|
|
@ -0,0 +1 @@
|
|||
sdk=33
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||