skills/healthcare-ui-design/references/android-implementation.md

19 KiB

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

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

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:

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

@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.

@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

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

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

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

Text("Heart Rate: 72 bpm", modifier = Modifier.semantics {
    contentDescription = "Heart rate is 72 beats per minute, normal range"
    stateDescription = "Normal"
})

Critical Alert Announcements

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):

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

@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

@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

@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

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

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:

@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

@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

@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

// 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