diff --git a/app/src/main/java/no/naiv/meddetsamme/MainActivity.kt b/app/src/main/java/no/naiv/meddetsamme/MainActivity.kt index ad64112..f6b4e9f 100644 --- a/app/src/main/java/no/naiv/meddetsamme/MainActivity.kt +++ b/app/src/main/java/no/naiv/meddetsamme/MainActivity.kt @@ -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() } } diff --git a/app/src/main/java/no/naiv/meddetsamme/ui/InventoryScreen.kt b/app/src/main/java/no/naiv/meddetsamme/ui/InventoryScreen.kt index 0080f79..e2dbcb8 100644 --- a/app/src/main/java/no/naiv/meddetsamme/ui/InventoryScreen.kt +++ b/app/src/main/java/no/naiv/meddetsamme/ui/InventoryScreen.kt @@ -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. diff --git a/app/src/main/java/no/naiv/meddetsamme/ui/ItemForm.kt b/app/src/main/java/no/naiv/meddetsamme/ui/ItemForm.kt index 26bd245..40870d7 100644 --- a/app/src/main/java/no/naiv/meddetsamme/ui/ItemForm.kt +++ b/app/src/main/java/no/naiv/meddetsamme/ui/ItemForm.kt @@ -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 diff --git a/app/src/main/java/no/naiv/meddetsamme/ui/MedListScreen.kt b/app/src/main/java/no/naiv/meddetsamme/ui/MedListScreen.kt index c598a52..fdf0e9e 100644 --- a/app/src/main/java/no/naiv/meddetsamme/ui/MedListScreen.kt +++ b/app/src/main/java/no/naiv/meddetsamme/ui/MedListScreen.kt @@ -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, ) } } diff --git a/app/src/main/java/no/naiv/meddetsamme/ui/Theme.kt b/app/src/main/java/no/naiv/meddetsamme/ui/Theme.kt new file mode 100644 index 0000000..db8c365 --- /dev/null +++ b/app/src/main/java/no/naiv/meddetsamme/ui/Theme.kt @@ -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), +) diff --git a/app/src/main/java/no/naiv/meddetsamme/ui/TodayScreen.kt b/app/src/main/java/no/naiv/meddetsamme/ui/TodayScreen.kt index d813457..f188bc5 100644 --- a/app/src/main/java/no/naiv/meddetsamme/ui/TodayScreen.kt +++ b/app/src/main/java/no/naiv/meddetsamme/ui/TodayScreen.kt @@ -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 diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 3293cf2..901868d 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,6 +1,9 @@ -