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:
parent
608d84236b
commit
6c605626c4
7 changed files with 224 additions and 23 deletions
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
83
app/src/main/java/no/naiv/meddetsamme/ui/Theme.kt
Normal file
83
app/src/main/java/no/naiv/meddetsamme/ui/Theme.kt
Normal 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),
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue