Visual refresh: Material You dynamic color, dark mode, status-coloured dose cards

MedDetSammeTheme: dynamic color on Android 12+ (follows the wallpaper),
green brand palette matching the launcher icon as fallback, dark mode
follows the system (the app was light-only until now). Today screen:
date subtitle, tonal time chips, status badges (Tatt/Venter/Utsatt/
Hoppet over) using scheme roles so contrast holds in both modes and
under any dynamic palette, overdue doses get error-container cards,
resolved ones fade back, FilledTonalButton for the primary action.
Apotek-ærend banner in secondaryContainer; centred empty state; letter
avatars and low-stock colour cues in the med/lager lists. Platform
window theme switched to DayNight so the pre-Compose flash matches.

Verified via emulator screenshots in light and dark mode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-06-11 16:43:32 +02:00
commit 6c605626c4
7 changed files with 224 additions and 23 deletions

View file

@ -4,15 +4,15 @@ import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.material3.MaterialTheme
import no.naiv.meddetsamme.ui.AppRoot import no.naiv.meddetsamme.ui.AppRoot
import no.naiv.meddetsamme.ui.MedDetSammeTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
MaterialTheme { MedDetSammeTheme {
AppRoot() AppRoot()
} }
} }

View file

@ -88,11 +88,17 @@ fun InventoryScreen(nav: Nav) {
}, },
) { ) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) { LetterAvatar(item.name)
Column(Modifier.padding(start = 12.dp).weight(1f)) {
Text("${item.name} ${item.strength}".trim(), style = MaterialTheme.typography.titleMedium) Text("${item.name} ${item.strength}".trim(), style = MaterialTheme.typography.titleMedium)
Text( Text(
ScheduleText.amountText(item.stockUnits, item.unit), ScheduleText.amountText(item.stockUnits, item.unit),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = if (item.stockUnits <= 0.0) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
) )
} }
// One-tap restock when the package size is known. // One-tap restock when the package size is known.

View file

@ -2,10 +2,12 @@ package no.naiv.meddetsamme.ui
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.DatePickerDialog
@ -71,6 +73,24 @@ fun guessForm(festForm: String): MedForm = when {
fun parseAmount(s: String): Double? = s.trim().replace(',', '.').toDoubleOrNull() fun parseAmount(s: String): Double? = s.trim().replace(',', '.').toDoubleOrNull()
/** Tonal circle with the preparation's initial — cheap visual anchor in lists. */
@Composable
fun LetterAvatar(name: String) {
androidx.compose.material3.Surface(
shape = androidx.compose.foundation.shape.CircleShape,
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(40.dp),
) {
Box(contentAlignment = Alignment.Center) {
Text(
name.firstOrNull()?.uppercase() ?: "?",
style = MaterialTheme.typography.titleMedium,
)
}
}
}
/** /**
* Browser link to Felleskatalogen's search for a preparation patient-facing * Browser link to Felleskatalogen's search for a preparation patient-facing
* reading only. Decision #8 bans Felleskatalogen as a DATA source (licensed * reading only. Decision #8 bans Felleskatalogen as a DATA source (licensed

View file

@ -84,11 +84,13 @@ fun MedListScreen(nav: Nav) {
}, },
) { ) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) { LetterAvatar(med.item.name)
Column(Modifier.padding(start = 12.dp).weight(1f)) {
Text(med.displayName, style = MaterialTheme.typography.titleMedium) Text(med.displayName, style = MaterialTheme.typography.titleMedium)
Text( Text(
"Lager: ${ScheduleText.amountText(med.item.stockUnits, med.item.unit)}", "Lager: ${ScheduleText.amountText(med.item.stockUnits, med.item.unit)}",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
} }

View file

@ -0,0 +1,83 @@
package no.naiv.meddetsamme.ui
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.graphics.Color
import androidx.compose.ui.platform.LocalContext
/**
* App theme: dynamic Material You colors on Android 12+ (follows the user's
* wallpaper personal by default), with a green brand palette matching the
* launcher icon as fallback. Dark mode follows the system.
*/
@Composable
fun MedDetSammeTheme(content: @Composable () -> Unit) {
val dark = isSystemInDarkTheme()
val context = LocalContext.current
val scheme = when {
Build.VERSION.SDK_INT >= 31 && dark -> dynamicDarkColorScheme(context)
Build.VERSION.SDK_INT >= 31 -> dynamicLightColorScheme(context)
dark -> DarkScheme
else -> LightScheme
}
MaterialTheme(colorScheme = scheme, content = content)
}
// Green-seeded Material 3 palette (seed ≈ launcher background #1B5E20).
private val LightScheme = lightColorScheme(
primary = Color(0xFF2E6C30),
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFB0F1AD),
onPrimaryContainer = Color(0xFF002204),
secondary = Color(0xFF526350),
onSecondary = Color(0xFFFFFFFF),
secondaryContainer = Color(0xFFD5E8D0),
onSecondaryContainer = Color(0xFF101F10),
tertiary = Color(0xFF39656B),
onTertiary = Color(0xFFFFFFFF),
tertiaryContainer = Color(0xFFBCEBF1),
onTertiaryContainer = Color(0xFF001F23),
error = Color(0xFFBA1A1A),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFF7FBF1),
onBackground = Color(0xFF181D17),
surface = Color(0xFFF7FBF1),
onSurface = Color(0xFF181D17),
surfaceVariant = Color(0xFFDEE5D8),
onSurfaceVariant = Color(0xFF424940),
outline = Color(0xFF72796F),
)
private val DarkScheme = darkColorScheme(
primary = Color(0xFF95D990),
onPrimary = Color(0xFF00390C),
primaryContainer = Color(0xFF155318),
onPrimaryContainer = Color(0xFFB0F1AD),
secondary = Color(0xFFB9CCB4),
onSecondary = Color(0xFF253423),
secondaryContainer = Color(0xFF3B4B39),
onSecondaryContainer = Color(0xFFD5E8D0),
tertiary = Color(0xFFA1CED5),
onTertiary = Color(0xFF00363C),
tertiaryContainer = Color(0xFF1F4D53),
onTertiaryContainer = Color(0xFFBCEBF1),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF101410),
onBackground = Color(0xFFE0E4DB),
surface = Color(0xFF101410),
onSurface = Color(0xFFE0E4DB),
surfaceVariant = Color(0xFF424940),
onSurfaceVariant = Color(0xFFC2C9BD),
outline = Color(0xFF8C9388),
)

View file

@ -13,14 +13,17 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.layout.size
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
@ -123,10 +126,26 @@ fun TodayScreen(nav: Nav) {
} }
} }
val dateLine = remember {
java.time.format.DateTimeFormatter
.ofPattern("EEEE d. MMMM", java.util.Locale.forLanguageTag("nb"))
.format(LocalDate.now())
.replaceFirstChar { it.uppercase() }
}
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("I dag") }, title = {
Column {
Text("I dag")
Text(
dateLine,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
actions = { actions = {
IconButton(onClick = { nav.push(Screen.MedList) }) { IconButton(onClick = { nav.push(Screen.MedList) }) {
Icon(painterResource(R.drawable.ic_meds), contentDescription = "Medisiner") Icon(painterResource(R.drawable.ic_meds), contentDescription = "Medisiner")
@ -148,8 +167,15 @@ fun TodayScreen(nav: Nav) {
item { BackupNudge(onSetup = { nav.push(Screen.Settings) }) } item { BackupNudge(onSetup = { nav.push(Screen.Settings) }) }
if (warnings.isNotEmpty()) { if (warnings.isNotEmpty()) {
item { item {
Card(modifier = Modifier.fillMaxWidth().semantics { contentDescription = "Varsler om lager og resept" }) { Card(
Column(Modifier.padding(12.dp)) { colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
),
modifier = Modifier.fillMaxWidth().semantics { contentDescription = "Varsler om lager og resept" },
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Apotek-ærend", style = MaterialTheme.typography.labelLarge)
warnings.forEach { Text(it, style = MaterialTheme.typography.bodyMedium) } warnings.forEach { Text(it, style = MaterialTheme.typography.bodyMedium) }
} }
} }
@ -157,11 +183,28 @@ fun TodayScreen(nav: Nav) {
} }
if (doses.isEmpty()) { if (doses.isEmpty()) {
item { item {
Text( Column(
"Ingen doser i dag. Legg til medisiner via pilleikonet øverst.", modifier = Modifier.fillMaxWidth().padding(vertical = 64.dp),
style = MaterialTheme.typography.bodyLarge, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(vertical = 24.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
painterResource(R.drawable.ic_meds),
contentDescription = null,
tint = MaterialTheme.colorScheme.outline,
modifier = Modifier.size(56.dp),
) )
Text(
"Ingen doser i dag",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
"Legg til medisiner via pilleikonet øverst.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline,
)
}
} }
} }
items(doses, key = { "${it.doseTime.id}-${it.atMillis}" }) { dose -> items(doses, key = { "${it.doseTime.id}-${it.atMillis}" }) { dose ->
@ -208,35 +251,61 @@ private fun DoseCard(
null -> null null -> null
} }
val resolved = dose.status == DoseStatus.TAKEN || dose.status == DoseStatus.SKIPPED val resolved = dose.status == DoseStatus.TAKEN || dose.status == DoseStatus.SKIPPED
val overdue = !resolved && dose.at.isBefore(LocalDateTime.now())
val scheme = MaterialTheme.colorScheme
// Visual state: overdue screams a little, resolved fades back, upcoming is calm.
val container = when {
overdue -> scheme.errorContainer
resolved -> scheme.surfaceContainerLow
else -> scheme.surfaceContainerHigh
}
Card( Card(
colors = CardDefaults.cardColors(containerColor = container),
modifier = Modifier.fillMaxWidth().semantics(mergeDescendants = true) { modifier = Modifier.fillMaxWidth().semantics(mergeDescendants = true) {
contentDescription = buildString { contentDescription = buildString {
append("$time, ${dose.med.displayName}, ") append("$time, ${dose.med.displayName}, ")
append(ScheduleText.amountText(dose.doseTime.amount, dose.med.item.unit)) append(ScheduleText.amountText(dose.doseTime.amount, dose.med.item.unit))
statusText?.let { append(", status: $it") } statusText?.let { append(", status: $it") }
if (overdue) append(", forfalt")
} }
}, },
) { ) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text(time, style = MaterialTheme.typography.titleLarge) Surface(
shape = MaterialTheme.shapes.medium,
color = if (overdue) scheme.error else scheme.primaryContainer,
contentColor = if (overdue) scheme.onError else scheme.onPrimaryContainer,
) {
Text(
time,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
)
}
Column(Modifier.padding(start = 12.dp).weight(1f)) { Column(Modifier.padding(start = 12.dp).weight(1f)) {
Text(dose.med.displayName, style = MaterialTheme.typography.titleMedium) Text(
val withFood = if (dose.med.med.withFood) " — tas med mat" else "" dose.med.displayName,
style = MaterialTheme.typography.titleMedium,
color = if (resolved) scheme.onSurfaceVariant else scheme.onSurface,
)
val withFood = if (dose.med.med.withFood) " · tas med mat" else ""
Text( Text(
ScheduleText.amountText(dose.doseTime.amount, dose.med.item.unit) + withFood, ScheduleText.amountText(dose.doseTime.amount, dose.med.item.unit) + withFood,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = scheme.onSurfaceVariant,
) )
} }
statusText?.let { Text(it, style = MaterialTheme.typography.labelLarge) } statusText?.let { StatusBadge(it, dose.status!!) }
} }
if (!resolved) { if (!resolved) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = onTake) { Text("Ta nå") } FilledTonalButton(onClick = onTake) { Text("Ta nå") }
OutlinedButton(onClick = onSkip) { Text("Hopp over") } TextButton(onClick = onSkip) { Text("Hopp over") }
if (onSnooze != null && dose.status != null) { if (onSnooze != null && dose.status != null) {
OutlinedButton(onClick = onSnooze) { Text("Utsett 15 min") } TextButton(onClick = onSnooze) { Text("Utsett") }
} }
} }
} else if (onUndo != null) { } else if (onUndo != null) {
@ -246,6 +315,24 @@ private fun DoseCard(
} }
} }
@Composable
private fun StatusBadge(text: String, status: DoseStatus) {
val scheme = MaterialTheme.colorScheme
val (bg, fg) = when (status) {
DoseStatus.TAKEN -> scheme.primary to scheme.onPrimary
DoseStatus.SNOOZED -> scheme.tertiaryContainer to scheme.onTertiaryContainer
DoseStatus.SKIPPED -> scheme.surfaceVariant to scheme.onSurfaceVariant
DoseStatus.PENDING -> scheme.secondaryContainer to scheme.onSecondaryContainer
}
Surface(shape = MaterialTheme.shapes.small, color = bg, contentColor = fg) {
Text(
text,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
)
}
}
/** /**
* Quiet one-time card while backup is unconfigured: health data existing only * Quiet one-time card while backup is unconfigured: health data existing only
* on this device is a real risk (proven the hard way on 2026-06-10). Goes * on this device is a real risk (proven the hard way on 2026-06-10). Goes

View file

@ -1,6 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<!-- Compose draws everything; this only styles the window before setContent. <!-- Compose draws everything; this only styles the window before setContent.
Platform theme, so no appcompat/material-components dependency. --> DayNight platform parent so the pre-Compose flash matches dark mode. -->
<style name="Theme.MedDetSamme" parent="android:Theme.Material.Light.NoActionBar" /> <style name="Theme.MedDetSamme" parent="android:Theme.DeviceDefault.DayNight" >
<item name="android:windowActionBar">false</item>
<item name="android:windowNoTitle">true</item>
</style>
</resources> </resources>