489 lines
19 KiB
Markdown
489 lines
19 KiB
Markdown
# Android Implementation Reference
|
|
|
|
Kotlin + Jetpack Compose + Material 3 + MVVM patterns for healthcare apps.
|
|
Companion to the main `healthcare-ui-design` skill. Follow `android-development` and `jetpack-compose-ui` skills as baseline.
|
|
|
|
## 1. Project Architecture
|
|
|
|
Follow `android-development` skill: MVVM + Clean Architecture + Hilt DI.
|
|
|
|
```
|
|
com.app.healthcare/
|
|
├── ui/
|
|
│ ├── theme/ # HealthcareTheme, Colors, Typography
|
|
│ ├── components/ # Reusable healthcare composables
|
|
│ ├── screens/
|
|
│ │ ├── dashboard/ # Home dashboard
|
|
│ │ ├── patients/ # Patient list, detail, search
|
|
│ │ ├── vitals/ # Vital signs entry and display
|
|
│ │ ├── medications/# Medication list, administration
|
|
│ │ ├── schedule/ # Appointments, calendar
|
|
│ │ ├── messaging/ # Secure chat
|
|
│ │ └── portal/ # Patient portal screens
|
|
│ └── navigation/ # NavHost, bottom nav, routes
|
|
├── domain/
|
|
│ ├── model/ # Patient, VitalSign, Medication, Appointment
|
|
│ └── usecase/ # Business logic
|
|
├── data/
|
|
│ ├── remote/ # API DTOs, Retrofit services
|
|
│ ├── local/ # Room entities, DAOs
|
|
│ └── repository/ # Repository implementations
|
|
└── util/ # Extensions, constants, formatters
|
|
```
|
|
|
|
Each screen folder contains `Screen.kt`, `ViewModel.kt`, and `UiState.kt`. Domain models are separate from Room entities and API DTOs -- map at the repository boundary.
|
|
|
|
## 2. Healthcare Theme Setup
|
|
|
|
### HealthcareColors.kt
|
|
|
|
```kotlin
|
|
object HealthcareColors {
|
|
val Primary = Color(0xFF2563EB); val PrimaryLight = Color(0xFFDBEAFE)
|
|
val Secondary = Color(0xFF0F766E); val SecondaryLight = Color(0xFFCCFBF1)
|
|
val Surface = Color(0xFFF8FAFC); val Background = Color(0xFFF1F5F9)
|
|
val Border = Color(0xFFE2E8F0)
|
|
val Critical = Color(0xFFDC2626); val CriticalLight = Color(0xFFFEF2F2)
|
|
val Warning = Color(0xFFD97706); val WarningLight = Color(0xFFFFFBEB)
|
|
val Success = Color(0xFF059669); val SuccessLight = Color(0xFFECFDF5)
|
|
val Info = Color(0xFF0284C7); val InfoLight = Color(0xFFF0F9FF)
|
|
val TextPrimary = Color(0xFF0F172A); val TextSecondary = Color(0xFF334155)
|
|
val TextMuted = Color(0xFF64748B); val TextInverse = Color(0xFFFFFFFF)
|
|
val Medication = Color(0xFF7C3AED); val MedicationLight = Color(0xFFF5F3FF)
|
|
val AllergyBg = Color(0xFFFEF2F2); val AllergyBorder = Color(0xFFDC2626)
|
|
}
|
|
```
|
|
|
|
### HealthcareTheme.kt
|
|
|
|
```kotlin
|
|
private val HealthcareColorScheme = lightColorScheme(
|
|
primary = HealthcareColors.Primary, onPrimary = HealthcareColors.TextInverse,
|
|
secondary = HealthcareColors.Secondary, onSecondary = HealthcareColors.TextInverse,
|
|
surface = HealthcareColors.Surface, onSurface = HealthcareColors.TextPrimary,
|
|
background = HealthcareColors.Background, onBackground = HealthcareColors.TextPrimary,
|
|
error = HealthcareColors.Critical, onError = HealthcareColors.TextInverse,
|
|
surfaceVariant = HealthcareColors.Background, outline = HealthcareColors.Border,
|
|
)
|
|
|
|
@Composable
|
|
fun HealthcareTheme(content: @Composable () -> Unit) {
|
|
MaterialTheme(colorScheme = HealthcareColorScheme, typography = HealthcareTypography, content = content)
|
|
}
|
|
```
|
|
|
|
### HealthcareTypography.kt
|
|
|
|
Full type scale is in `design-tokens.md`. Clinical-specific extensions:
|
|
|
|
```kotlin
|
|
object ClinicalTextStyles {
|
|
val VitalValue = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 24.sp, fontWeight = FontWeight.W700, lineHeight = 28.sp)
|
|
val PatientName = TextStyle(fontSize = 18.sp, fontWeight = FontWeight.W600, lineHeight = 24.sp)
|
|
val MrnDisplay = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.W500, letterSpacing = 0.8.sp)
|
|
}
|
|
```
|
|
|
|
## 3. Core Healthcare Composables
|
|
|
|
### PatientCard
|
|
|
|
```kotlin
|
|
@Composable
|
|
fun PatientCard(patient: Patient, onTap: () -> Unit, modifier: Modifier = Modifier) {
|
|
Card(
|
|
onClick = onTap, modifier = modifier.fillMaxWidth(),
|
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
|
|
) {
|
|
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
PatientAvatar(patient.photoUrl, size = 48.dp)
|
|
Spacer(Modifier.width(12.dp))
|
|
Column(modifier = Modifier.weight(1f)) {
|
|
Text(patient.fullName, style = MaterialTheme.typography.titleMedium)
|
|
Text("${patient.gender}, ${patient.age}y | MRN: ${patient.mrn}",
|
|
style = MaterialTheme.typography.bodySmall, color = HealthcareColors.TextMuted)
|
|
}
|
|
RiskBadge(patient.riskLevel)
|
|
}
|
|
if (patient.allergies.isNotEmpty()) { AllergyBanner(patient.allergies) }
|
|
}
|
|
}
|
|
```
|
|
|
|
### AllergyBanner (ALWAYS Visible -- Never Hide)
|
|
|
|
Allergy visibility is a patient safety requirement. Never hide, collapse, or scroll off-screen.
|
|
|
|
```kotlin
|
|
@Composable
|
|
fun AllergyBanner(allergies: List<String>, modifier: Modifier = Modifier) {
|
|
Surface(
|
|
modifier = modifier.fillMaxWidth(),
|
|
color = HealthcareColors.AllergyBg, border = BorderStroke(1.dp, HealthcareColors.AllergyBorder)
|
|
) {
|
|
Row(Modifier.padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
Icon(Icons.Default.Warning, contentDescription = "Allergy", tint = HealthcareColors.Critical)
|
|
Spacer(Modifier.width(8.dp))
|
|
Text("Allergies: ${allergies.joinToString(", ")}",
|
|
style = MaterialTheme.typography.labelMedium, color = HealthcareColors.Critical,
|
|
modifier = Modifier.semantics { stateDescription = "Patient has allergies" })
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### VitalSignCard
|
|
|
|
```kotlin
|
|
enum class VitalStatus(val label: String) { NORMAL("Normal"), WARNING("Warning"), CRITICAL("Critical") }
|
|
enum class VitalTrend { UP, DOWN, STABLE }
|
|
|
|
@Composable
|
|
fun VitalSignCard(label: String, value: String, unit: String, status: VitalStatus, trend: VitalTrend? = null) {
|
|
val statusColor = when (status) {
|
|
VitalStatus.NORMAL -> HealthcareColors.Success
|
|
VitalStatus.WARNING -> HealthcareColors.Warning
|
|
VitalStatus.CRITICAL -> HealthcareColors.Critical
|
|
}
|
|
Card(colors = CardDefaults.cardColors(containerColor = statusColor.copy(alpha = 0.1f))) {
|
|
Column(Modifier.padding(12.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
|
Text(label, style = MaterialTheme.typography.labelSmall, color = HealthcareColors.TextMuted)
|
|
Row(verticalAlignment = Alignment.Bottom) {
|
|
Text(value, style = MaterialTheme.typography.headlineMedium.copy(
|
|
fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold), color = statusColor)
|
|
trend?.let { TrendArrow(it) }
|
|
}
|
|
Text("$unit - ${status.label}", style = MaterialTheme.typography.labelSmall)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun TrendArrow(trend: VitalTrend) {
|
|
val (icon, color) = when (trend) {
|
|
VitalTrend.UP -> Icons.Default.TrendingUp to HealthcareColors.Critical
|
|
VitalTrend.DOWN -> Icons.Default.TrendingDown to HealthcareColors.Success
|
|
VitalTrend.STABLE -> Icons.Default.TrendingFlat to HealthcareColors.TextMuted
|
|
}
|
|
Icon(icon, contentDescription = trend.name, tint = color, modifier = Modifier.size(20.dp))
|
|
}
|
|
```
|
|
|
|
### ClinicalAlert
|
|
|
|
```kotlin
|
|
enum class AlertLevel { CRITICAL, WARNING, INFO, SUCCESS }
|
|
|
|
@Composable
|
|
fun ClinicalAlert(
|
|
level: AlertLevel, title: String, message: String,
|
|
onAcknowledge: (() -> Unit)? = null, onDismiss: (() -> Unit)? = null
|
|
) {
|
|
val (bgColor, icon) = when (level) {
|
|
AlertLevel.CRITICAL -> HealthcareColors.Critical to Icons.Default.GppBad
|
|
AlertLevel.WARNING -> HealthcareColors.Warning to Icons.Default.Warning
|
|
AlertLevel.INFO -> HealthcareColors.Info to Icons.Default.Info
|
|
AlertLevel.SUCCESS -> HealthcareColors.Success to Icons.Default.CheckCircle
|
|
}
|
|
Surface(
|
|
modifier = Modifier.fillMaxWidth().semantics { liveRegion = LiveRegionMode.Assertive },
|
|
color = bgColor.copy(alpha = 0.1f), border = BorderStroke(1.dp, bgColor)
|
|
) {
|
|
Row(Modifier.padding(16.dp)) {
|
|
Icon(icon, contentDescription = level.name, tint = bgColor)
|
|
Spacer(Modifier.width(12.dp))
|
|
Column(Modifier.weight(1f)) {
|
|
Text(title, style = MaterialTheme.typography.titleSmall, color = bgColor)
|
|
Spacer(Modifier.height(4.dp))
|
|
Text(message, style = MaterialTheme.typography.bodySmall)
|
|
}
|
|
onAcknowledge?.let {
|
|
Button(onClick = it, colors = ButtonDefaults.buttonColors(containerColor = bgColor)) {
|
|
Text("Acknowledge")
|
|
}
|
|
}
|
|
onDismiss?.let {
|
|
IconButton(onClick = it) { Icon(Icons.Default.Close, contentDescription = "Dismiss") }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## 4. Navigation Structure
|
|
|
|
```kotlin
|
|
sealed class HealthcareRoute(val route: String) {
|
|
object Dashboard : HealthcareRoute("dashboard")
|
|
object PatientList : HealthcareRoute("patients")
|
|
object PatientDetail : HealthcareRoute("patients/{patientId}")
|
|
object VitalsEntry : HealthcareRoute("patients/{patientId}/vitals/entry")
|
|
object MedicationAdmin : HealthcareRoute("patients/{patientId}/medications/admin")
|
|
object Scheduling : HealthcareRoute("scheduling")
|
|
object Messages : HealthcareRoute("messages")
|
|
object Profile : HealthcareRoute("profile")
|
|
}
|
|
|
|
val bottomNavItems = listOf(
|
|
BottomNavItem("Home", Icons.Default.Home, HealthcareRoute.Dashboard.route),
|
|
BottomNavItem("Patients", Icons.Default.People, HealthcareRoute.PatientList.route),
|
|
BottomNavItem("Schedule", Icons.Default.CalendarMonth, HealthcareRoute.Scheduling.route),
|
|
BottomNavItem("Messages", Icons.Default.Chat, HealthcareRoute.Messages.route),
|
|
BottomNavItem("Profile", Icons.Default.Person, HealthcareRoute.Profile.route),
|
|
)
|
|
|
|
@Composable
|
|
fun HealthcareNavHost(navController: NavHostController) {
|
|
NavHost(navController = navController, startDestination = HealthcareRoute.Dashboard.route) {
|
|
composable(HealthcareRoute.Dashboard.route) { DashboardScreen(navController) }
|
|
composable(HealthcareRoute.PatientList.route) { PatientListScreen(navController) }
|
|
composable(HealthcareRoute.PatientDetail.route,
|
|
arguments = listOf(navArgument("patientId") { type = NavType.StringType })
|
|
) { entry -> PatientDetailScreen(entry.arguments?.getString("patientId") ?: "", navController) }
|
|
composable(HealthcareRoute.Scheduling.route) { SchedulingScreen(navController) }
|
|
composable(HealthcareRoute.Messages.route) { MessagesScreen(navController) }
|
|
composable(HealthcareRoute.Profile.route) { ProfileScreen(navController) }
|
|
}
|
|
}
|
|
```
|
|
|
|
## 5. Accessibility for Healthcare (Compose)
|
|
|
|
### Semantics on Clinical Data
|
|
|
|
```kotlin
|
|
Text("Heart Rate: 72 bpm", modifier = Modifier.semantics {
|
|
contentDescription = "Heart rate is 72 beats per minute, normal range"
|
|
stateDescription = "Normal"
|
|
})
|
|
```
|
|
|
|
### Critical Alert Announcements
|
|
|
|
```kotlin
|
|
val context = LocalContext.current
|
|
LaunchedEffect(criticalAlert) {
|
|
criticalAlert?.let {
|
|
val manager = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
|
|
if (manager.isEnabled) {
|
|
val event = AccessibilityEvent.obtain(AccessibilityEvent.TYPE_ANNOUNCEMENT)
|
|
.apply { text.add(it.message) }
|
|
manager.sendAccessibilityEvent(event)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Touch Targets and Focus
|
|
|
|
Clinical environments involve gloved hands. Minimum 56dp for clinical action buttons (exceeds 48dp Material minimum):
|
|
|
|
```kotlin
|
|
IconButton(onClick = { /* action */ }, modifier = Modifier.size(56.dp)) {
|
|
Icon(Icons.Default.Favorite, contentDescription = "Record vital sign")
|
|
}
|
|
// Auto-focus critical fields when entering vital signs
|
|
val focusRequester = remember { FocusRequester() }
|
|
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
|
OutlinedTextField(
|
|
value = systolicValue, onValueChange = { onSystolicChange(it) },
|
|
label = { Text("Systolic (mmHg)") },
|
|
modifier = Modifier.focusRequester(focusRequester),
|
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
|
)
|
|
```
|
|
|
|
## 6. Offline-First Healthcare Patterns
|
|
|
|
Follow `android-data-persistence` skill for Room setup and sync architecture.
|
|
|
|
| Action | Online | Offline |
|
|
|--------|--------|---------|
|
|
| Patient roster | Pull from API | Read from Room cache |
|
|
| Vital signs entry | POST to API | Queue in Room, sync later |
|
|
| Medication admin | POST to API | Queue in Room, sync later |
|
|
| Clinical notes | POST to API | Save locally, sync later |
|
|
| Patient photos | Download on demand | Serve from Coil disk cache |
|
|
|
|
### Offline Mode Banner
|
|
|
|
```kotlin
|
|
@Composable
|
|
fun OfflineModeBanner(isOffline: Boolean) {
|
|
AnimatedVisibility(visible = isOffline) {
|
|
Surface(color = HealthcareColors.Warning.copy(alpha = 0.15f), modifier = Modifier.fillMaxWidth()) {
|
|
Row(Modifier.padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
Icon(Icons.Default.CloudOff, contentDescription = null, tint = HealthcareColors.Warning)
|
|
Spacer(Modifier.width(8.dp))
|
|
Text("Offline Mode - Data will sync when connected",
|
|
style = MaterialTheme.typography.labelMedium, color = HealthcareColors.Warning)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Data Freshness Indicator
|
|
|
|
```kotlin
|
|
@Composable
|
|
fun DataFreshnessLabel(lastSyncTime: Instant?) {
|
|
val text = lastSyncTime?.let {
|
|
val minutes = Duration.between(it, Instant.now()).toMinutes()
|
|
when {
|
|
minutes < 1 -> "Just synced"; minutes < 60 -> "Last synced: ${minutes}min ago"
|
|
else -> "Last synced: ${minutes / 60}h ago"
|
|
}
|
|
} ?: "Never synced"
|
|
Text(text, style = MaterialTheme.typography.labelSmall, color = HealthcareColors.TextMuted)
|
|
}
|
|
```
|
|
|
|
### Sync Queue Entity
|
|
|
|
```kotlin
|
|
@Entity(tableName = "sync_queue")
|
|
data class SyncQueueItem(
|
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
|
val entityType: String, // "vital_sign", "medication_admin", "clinical_note"
|
|
val entityId: String,
|
|
val payload: String, // JSON serialized data
|
|
val createdAt: Long = System.currentTimeMillis(),
|
|
val retryCount: Int = 0,
|
|
val status: String = "pending" // pending, syncing, failed, completed
|
|
)
|
|
```
|
|
|
|
## 7. Security for Healthcare Android
|
|
|
|
### FLAG_SECURE on All PHI Screens
|
|
|
|
```kotlin
|
|
val activity = LocalContext.current as? Activity
|
|
DisposableEffect(Unit) {
|
|
activity?.window?.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)
|
|
onDispose { activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) }
|
|
}
|
|
```
|
|
|
|
### Required Security Measures
|
|
|
|
| Measure | Implementation |
|
|
|---------|---------------|
|
|
| Biometric auth | Use `android-biometric-login` skill. Require on app launch. |
|
|
| Token storage | `EncryptedSharedPreferences` for JWT, refresh tokens, session data |
|
|
| Certificate pinning | OkHttp `CertificatePinner` for all API endpoints |
|
|
| App lock | Re-authenticate after 30 seconds in background |
|
|
| Screenshot block | `FLAG_SECURE` on every screen displaying PHI |
|
|
| Obfuscation | R8/ProGuard enabled for release builds |
|
|
| Audit logging | Log every patient record access with user ID, timestamp, MRN |
|
|
|
|
### App Lock on Background
|
|
|
|
```kotlin
|
|
class HealthcareApp : Application(), DefaultLifecycleObserver {
|
|
private var backgroundTimestamp: Long = 0L
|
|
override fun onStop(owner: LifecycleOwner) { backgroundTimestamp = System.currentTimeMillis() }
|
|
override fun onStart(owner: LifecycleOwner) {
|
|
if (System.currentTimeMillis() - backgroundTimestamp > 30_000) {
|
|
EventBus.post(RequireReAuthEvent)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## 8. Performance Patterns
|
|
|
|
### Paginated Patient Lists
|
|
|
|
Follow `api-pagination` skill. Use Paging 3 for infinite scroll:
|
|
|
|
```kotlin
|
|
@HiltViewModel
|
|
class PatientListViewModel @Inject constructor(private val repository: PatientRepository) : ViewModel() {
|
|
val patients: Flow<PagingData<Patient>> = Pager(
|
|
config = PagingConfig(pageSize = 25, prefetchDistance = 5),
|
|
pagingSourceFactory = { repository.patientPagingSource() }
|
|
).flow.cachedIn(viewModelScope)
|
|
}
|
|
|
|
@Composable
|
|
fun PatientListContent(patients: LazyPagingItems<Patient>, onPatientClick: (String) -> Unit) {
|
|
LazyColumn {
|
|
items(count = patients.itemCount, key = patients.itemKey { it.mrn }) { index ->
|
|
patients[index]?.let { PatientCard(patient = it, onTap = { onPatientClick(it.mrn) }) }
|
|
}
|
|
item {
|
|
if (patients.loadState.append is LoadState.Loading) {
|
|
CircularProgressIndicator(Modifier.fillMaxWidth().padding(16.dp))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Image Loading with Coil
|
|
|
|
```kotlin
|
|
@Composable
|
|
fun PatientAvatar(photoUrl: String?, size: Dp = 48.dp) {
|
|
AsyncImage(
|
|
model = ImageRequest.Builder(LocalContext.current).data(photoUrl).crossfade(true)
|
|
.diskCachePolicy(CachePolicy.ENABLED).memoryCachePolicy(CachePolicy.ENABLED).build(),
|
|
contentDescription = "Patient photo",
|
|
modifier = Modifier.size(size).clip(CircleShape),
|
|
placeholder = painterResource(R.drawable.ic_patient_placeholder),
|
|
error = painterResource(R.drawable.ic_patient_placeholder),
|
|
contentScale = ContentScale.Crop
|
|
)
|
|
}
|
|
```
|
|
|
|
### Skeleton Loading Screens
|
|
|
|
```kotlin
|
|
@Composable
|
|
fun PatientCardSkeleton(modifier: Modifier = Modifier) {
|
|
val shimmer = rememberInfiniteTransition(label = "shimmer")
|
|
val alpha by shimmer.animateFloat(
|
|
initialValue = 0.3f, targetValue = 1f,
|
|
animationSpec = infiniteRepeatable(tween(1500), RepeatMode.Reverse), label = "alpha"
|
|
)
|
|
Card(modifier = modifier.fillMaxWidth()) {
|
|
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
Box(Modifier.size(48.dp).clip(CircleShape).background(Color.LightGray.copy(alpha = alpha)))
|
|
Spacer(Modifier.width(12.dp))
|
|
Column {
|
|
Box(Modifier.width(160.dp).height(16.dp).background(Color.LightGray.copy(alpha = alpha)))
|
|
Spacer(Modifier.height(8.dp))
|
|
Box(Modifier.width(120.dp).height(12.dp).background(Color.LightGray.copy(alpha = alpha)))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Prefetch Strategy
|
|
|
|
```kotlin
|
|
// Prefetch patient detail when item becomes visible in list
|
|
LaunchedEffect(visiblePatientIds) {
|
|
visiblePatientIds.forEach { mrn -> launch { repository.prefetchPatientDetail(mrn) } }
|
|
}
|
|
```
|
|
|
|
## Companion Skill Cross-References
|
|
|
|
| Need | Skill to Load |
|
|
|------|---------------|
|
|
| Full MVVM + Hilt setup | `android-development` |
|
|
| Compose UI patterns | `jetpack-compose-ui` |
|
|
| Biometric login | `android-biometric-login` |
|
|
| Room + offline sync | `android-data-persistence` |
|
|
| Infinite scroll pagination | `api-pagination` |
|
|
| RBAC permission gates | `mobile-rbac` |
|
|
| PDF export | `android-pdf-export` |
|
|
| Custom PNG icons | `android-custom-icons` |
|
|
| Report tables (>25 rows) | `android-report-tables` |
|
|
| Security baseline | `vibe-security-skill` |
|