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.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.material3.MaterialTheme
import no.naiv.meddetsamme.ui.AppRoot
import no.naiv.meddetsamme.ui.MedDetSammeTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MaterialTheme {
MedDetSammeTheme {
AppRoot()
}
}

View file

@ -88,11 +88,17 @@ fun InventoryScreen(nav: Nav) {
},
) {
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(
ScheduleText.amountText(item.stockUnits, item.unit),
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.

View file

@ -2,10 +2,12 @@ package no.naiv.meddetsamme.ui
import androidx.compose.foundation.clickable
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Card
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
@ -71,6 +73,24 @@ fun guessForm(festForm: String): MedForm = when {
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
* 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) {
Column(Modifier.weight(1f)) {
LetterAvatar(med.item.name)
Column(Modifier.padding(start = 12.dp).weight(1f)) {
Text(med.displayName, style = MaterialTheme.typography.titleMedium)
Text(
"Lager: ${ScheduleText.amountText(med.item.stockUnits, med.item.unit)}",
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.layout.size
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
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.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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(
topBar = {
TopAppBar(
title = { Text("I dag") },
title = {
Column {
Text("I dag")
Text(
dateLine,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
actions = {
IconButton(onClick = { nav.push(Screen.MedList) }) {
Icon(painterResource(R.drawable.ic_meds), contentDescription = "Medisiner")
@ -148,8 +167,15 @@ fun TodayScreen(nav: Nav) {
item { BackupNudge(onSetup = { nav.push(Screen.Settings) }) }
if (warnings.isNotEmpty()) {
item {
Card(modifier = Modifier.fillMaxWidth().semantics { contentDescription = "Varsler om lager og resept" }) {
Column(Modifier.padding(12.dp)) {
Card(
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) }
}
}
@ -157,11 +183,28 @@ fun TodayScreen(nav: Nav) {
}
if (doses.isEmpty()) {
item {
Text(
"Ingen doser i dag. Legg til medisiner via pilleikonet øverst.",
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(vertical = 24.dp),
)
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 64.dp),
horizontalAlignment = Alignment.CenterHorizontally,
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 ->
@ -208,35 +251,61 @@ private fun DoseCard(
null -> null
}
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(
colors = CardDefaults.cardColors(containerColor = container),
modifier = Modifier.fillMaxWidth().semantics(mergeDescendants = true) {
contentDescription = buildString {
append("$time, ${dose.med.displayName}, ")
append(ScheduleText.amountText(dose.doseTime.amount, dose.med.item.unit))
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) {
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)) {
Text(dose.med.displayName, style = MaterialTheme.typography.titleMedium)
val withFood = if (dose.med.med.withFood) " — tas med mat" else ""
Text(
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(
ScheduleText.amountText(dose.doseTime.amount, dose.med.item.unit) + withFood,
style = MaterialTheme.typography.bodyMedium,
color = scheme.onSurfaceVariant,
)
}
statusText?.let { Text(it, style = MaterialTheme.typography.labelLarge) }
statusText?.let { StatusBadge(it, dose.status!!) }
}
if (!resolved) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = onTake) { Text("Ta nå") }
OutlinedButton(onClick = onSkip) { Text("Hopp over") }
FilledTonalButton(onClick = onTake) { Text("Ta nå") }
TextButton(onClick = onSkip) { Text("Hopp over") }
if (onSnooze != null && dose.status != null) {
OutlinedButton(onClick = onSnooze) { Text("Utsett 15 min") }
TextButton(onClick = onSnooze) { Text("Utsett") }
}
}
} 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
* 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"?>
<resources>
<!-- Compose draws everything; this only styles the window before setContent.
Platform theme, so no appcompat/material-components dependency. -->
<style name="Theme.MedDetSamme" parent="android:Theme.Material.Light.NoActionBar" />
DayNight platform parent so the pre-Compose flash matches dark mode. -->
<style name="Theme.MedDetSamme" parent="android:Theme.DeviceDefault.DayNight" >
<item name="android:windowActionBar">false</item>
<item name="android:windowNoTitle">true</item>
</style>
</resources>