Add a black theme for AMOLED screens (#181) (#399)
Beta — Codeberg pre-release / detect (push) Successful in 7s
Beta — Codeberg pre-release / beta (push) Skipped

Settings → Appearance → Theme gets a fourth option, Black, next to System, Light and Dark. It's the dark theme with pure black backgrounds, and it works with Material You as well as Calendula's own palette.

- The month cells and the hour grid are `surfaceContainer` tiles on a `surface` ground, so making every container black would erase the grid lines. floret-kit's `toPureBlack()` sets the ground to #000 and scales the containers down to about #13, #1A, #25 and #30. The tiles stay a shade above black, and sheets and menus still stand out from the page.
- Black always means dark. It's an option in the theme picker, so it doesn't follow the system's light/dark setting.
- Event colours read `isSystemInDarkTheme()` in 15 places, so an in-app Dark or Black on a phone in light mode got light-theme tints. They now read `LocalFloretDarkTheme`, the theme's own decision. The bug also affected Dark, so the changelog has a Fixed entry for it.
- The window backdrop came from the night resource qualifier, so an in-app theme flashed the system's light or dark background before the first frame. `WindowThemeCache` keeps a SharedPreferences copy of the theme choice, and `onCreate` reads it to set the backdrop and bar icons. The copy is written when the stored setting loads, so the first cold start after updating still shows the old backdrop.
- Widgets keep their current themes. They get their theme work in the next milestone.

Needs jlmakiola/floret-kit#13, which is merged. The submodule is pinned to its merge commit on floret-kit `main` (`b1905da`).

Tested with new unit tests for the transform and for saving and reloading `BLACK`, and by hand on the emulator.

Closes #181

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/399
This commit is contained in:
Jean-Luc Makiolaandmakiolaj committed 2026-10-14 17:41:34 +02:00
1 parent 0dd7e07aa0
commit cf3c4e96cd
20 files changed
+128 -36

No files matched your search

@@ -25,8 +25,10 @@ import androidx.core.content.IntentCompat
import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.domain.EventForm
@@ -51,8 +53,11 @@ import de.jeanlucmakiola.floret.crash.CrashReportDialog
import de.jeanlucmakiola.floret.crash.CrashReporter
import de.jeanlucmakiola.floret.crash.submitCrashReport
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
import de.jeanlucmakiola.calendula.ui.theme.WindowThemeCache
import de.jeanlucmakiola.calendula.ui.theme.windowBackgroundRes
import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
@@ -67,6 +72,7 @@ private data class InsertRequest(val form: EventForm, val source: ImportSource)
class MainActivity : AppCompatActivity() {
@Inject lateinit var calendarRepository: CalendarRepository
@Inject lateinit var settingsPrefs: SettingsPrefs
// Which of light/dark the system bars are drawn for. The styles installed
// in onCreate read this field live, so androidx's config-change replay
@@ -124,9 +130,25 @@ class MainActivity : AppCompatActivity() {
return
}
systemBarsDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
val systemDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
Configuration.UI_MODE_NIGHT_YES
// The stored theme only arrives after the first frame, so a Light, Dark
// or Black override starts from its last mirrored copy: the matching
// backdrop and bar icons instead of the night qualifier's (#181).
val cachedTheme = WindowThemeCache.read(this)
cachedTheme?.windowBackgroundRes()?.let { window.setBackgroundDrawableResource(it) }
systemBarsDark = when (cachedTheme) {
null, ThemeMode.SYSTEM -> systemDark
ThemeMode.LIGHT -> false
ThemeMode.DARK, ThemeMode.BLACK -> true
}
applyEdgeToEdge()
lifecycleScope.launch {
settingsPrefs.themeMode.collect { mode ->
WindowThemeCache.write(this@MainActivity, mode)
window.setBackgroundDrawableResource(mode.windowBackgroundRes() ?: R.color.window_background)
}
}
// Android hands the intent that started the task back to onCreate every
// time the activity is recreated — on a rotation, or on a restart after
// the system reclaimed the process. Re-running a launch that opens one
@@ -161,7 +183,7 @@ class MainActivity : AppCompatActivity() {
val darkTheme = when (settings.themeMode) {
ThemeMode.SYSTEM -> isSystemInDarkTheme()
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
ThemeMode.DARK, ThemeMode.BLACK -> true
}
// onCreate can only see the night resource qualifier, not the in-app
// override — re-apply from the resolved theme so the bar icons follow
@@ -201,6 +223,7 @@ class MainActivity : AppCompatActivity() {
CalendulaTheme(
darkTheme = darkTheme,
dynamicColor = settings.dynamicColor,
pureBlack = settings.themeMode == ThemeMode.BLACK,
typography = typography,
) {
Box(modifier = Modifier.fillMaxSize()) {
@@ -41,8 +41,11 @@ import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
/** Light/dark override. SYSTEM follows the device setting. */
enum class ThemeMode { SYSTEM, LIGHT, DARK }
/**
* Light/dark override. SYSTEM follows the device setting; BLACK is dark with
* pure-black surfaces, for AMOLED screens (#181).
*/
enum class ThemeMode { SYSTEM, LIGHT, DARK, BLACK }
/**
* Week-start override. [Auto] derives the first day from the active locale;
@@ -2,7 +2,6 @@ package de.jeanlucmakiola.calendula.ui.agenda
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -39,6 +38,7 @@ import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import kotlinx.datetime.DateTimeUnit
@@ -127,7 +127,7 @@ internal fun AgendaEventRow(
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
val dark = isSystemInDarkTheme()
val dark = LocalFloretDarkTheme.current
val soften = LocalSoftenColors.current
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
GroupedRow(
@@ -6,7 +6,6 @@ import android.content.Intent
import android.provider.Settings
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -95,6 +94,7 @@ import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.identity.predictiveBack
@@ -478,7 +478,7 @@ private fun CalendarNameField(
onNameChange: (String) -> Unit,
onImeAction: () -> Unit,
) {
val dark = isSystemInDarkTheme()
val dark = LocalFloretDarkTheme.current
val soften = LocalSoftenColors.current
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
Row(
@@ -522,7 +522,7 @@ private fun CalendarNameField(
*/
@Composable
private fun CalendarColorGrid(selected: Int, onSelect: (Int) -> Unit) {
val dark = isSystemInDarkTheme()
val dark = LocalFloretDarkTheme.current
val soften = LocalSoftenColors.current
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
Column(
@@ -1,7 +1,6 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
@@ -14,6 +13,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
/**
* Leading avatar for a calendar: a neutral chip holding a calendar glyph tinted
@@ -23,7 +23,7 @@ import androidx.compose.ui.unit.dp
*/
@Composable
fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
val dark = isSystemInDarkTheme()
val dark = LocalFloretDarkTheme.current
val soften = LocalSoftenColors.current
Box(
modifier = modifier
@@ -6,7 +6,6 @@ import androidx.compose.animation.core.tween
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
@@ -56,6 +55,7 @@ import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import de.jeanlucmakiola.calendula.ui.week.minutesPast
import de.jeanlucmakiola.calendula.ui.week.wallClockEndMinutes
import de.jeanlucmakiola.calendula.ui.week.wallClockMinutes
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.floret.locale.currentLocale
import kotlinx.coroutines.delay
@@ -1152,7 +1152,7 @@ const val SETTLE_FADE_MILLIS: Int = 250
fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) {
var origin by remember { mutableStateOf(Offset.Zero) }
val density = LocalDensity.current
val dark = isSystemInDarkTheme()
val dark = LocalFloretDarkTheme.current
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val reduceMotion = rememberReduceMotion()
@@ -5,7 +5,6 @@ import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -93,6 +92,7 @@ import de.jeanlucmakiola.calendula.domain.RichText
import de.jeanlucmakiola.calendula.domain.TimeZoneOption
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.DeleteEventDialog
@@ -303,7 +303,7 @@ private fun EventDetailContent(
) {
val detail = state.detail
val instance = detail.instance
val dark = isSystemInDarkTheme()
val dark = LocalFloretDarkTheme.current
val locale = currentDetailLocale()
val accent = eventAccent(instance.color, dark, LocalSoftenColors.current)
@@ -13,7 +13,6 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
@@ -36,6 +35,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Notes
import androidx.compose.material.icons.filled.Add
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import kotlinx.coroutines.launch
import de.jeanlucmakiola.calendula.ui.common.sendEventShare
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGate
@@ -703,7 +703,7 @@ private fun EventEditContent(
val locale = currentLocale()
val deviceZone by viewModel.zone.collectAsStateWithLifecycle()
val firstDayOfWeek by viewModel.firstDayOfWeek.collectAsStateWithLifecycle()
val dark = isSystemInDarkTheme()
val dark = LocalFloretDarkTheme.current
// The title, date and recurrence are managed by the special-dates sync, so
// they're locked here; everything else (reminders, location, notes) is the
// user's to edit.
@@ -2299,7 +2299,7 @@ private fun ColorPickerDialog(
onClear: () -> Unit,
onDismiss: () -> Unit,
) {
val dark = isSystemInDarkTheme()
val dark = LocalFloretDarkTheme.current
FullScreenPicker(
title = stringResource(R.string.event_edit_color),
onDismiss = onDismiss,
@@ -24,6 +24,7 @@ import de.jeanlucmakiola.calendula.ui.common.morphEvent
import de.jeanlucmakiola.calendula.ui.common.morphPageActive
import de.jeanlucmakiola.calendula.ui.common.jumpToPage
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.drop
import androidx.compose.animation.ExperimentalSharedTransitionApi
@@ -46,7 +47,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
@@ -589,7 +589,7 @@ private const val MONTH_SETTLE_TIMEOUT_MILLIS = 450L
@Composable
private fun MonthDragOverlay(controller: MonthDragController) {
var origin by remember { mutableStateOf(Offset.Zero) }
val dark = isSystemInDarkTheme()
val dark = LocalFloretDarkTheme.current
val density = LocalDensity.current
val timeChipWidth = rememberMonthTimeChipWidth()
val reduceMotion = rememberReduceMotion()
@@ -1722,7 +1722,7 @@ internal fun SplitMonthGrid(
onSelectDay: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
) {
val dark = isSystemInDarkTheme()
val dark = LocalFloretDarkTheme.current
val month = state.month
val hasToday = state.today.year == month.year && state.today.month == month.month
val ripple = rememberTodayRipple(active = hasToday)
@@ -2231,7 +2231,7 @@ private fun MonthWeekRow(
*/
selected: LocalDate? = null,
) {
val dark = isSystemInDarkTheme()
val dark = LocalFloretDarkTheme.current
val todayCol = week.days.indexOf(today).takeIf { it >= 0 && inMonth(today) }
val ripple = rememberTodayRipple(active = todayCol != null)
val overflowRow = rememberOverflowRowHeight()
@@ -8,7 +8,6 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -83,6 +82,7 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.SearchHit
import de.jeanlucmakiola.calendula.domain.SearchMonth
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.floret.identity.fadeThrough
import de.jeanlucmakiola.floret.identity.predictiveBack
@@ -513,7 +513,7 @@ private fun SearchResultRow(
// Spans are empty for a blank title — the query never matches the
// placeholder — so marking the substitute is safe.
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val dark = isSystemInDarkTheme()
val dark = LocalFloretDarkTheme.current
val soften = LocalSoftenColors.current
// On a picked row the headline is already recoloured for the secondary
// container, so the match is carried by weight alone there.
@@ -215,13 +215,13 @@ internal fun AppearanceScreen(
selected = state.themeMode,
label = { themeLabel(it) },
summary = { mode ->
if (mode == ThemeMode.SYSTEM) {
stringResource(
when (mode) {
ThemeMode.SYSTEM -> stringResource(
R.string.settings_theme_system_summary,
themeLabel(if (systemDark) ThemeMode.DARK else ThemeMode.LIGHT),
)
} else {
null
ThemeMode.BLACK -> stringResource(R.string.settings_theme_black_summary)
ThemeMode.LIGHT, ThemeMode.DARK -> null
}
},
onSelect = viewModel::setThemeMode,
@@ -258,6 +258,7 @@ private fun themeLabel(mode: ThemeMode): String = stringResource(
ThemeMode.SYSTEM -> R.string.settings_theme_system
ThemeMode.LIGHT -> R.string.settings_theme_light
ThemeMode.DARK -> R.string.settings_theme_dark
ThemeMode.BLACK -> R.string.settings_theme_black
},
)
@@ -16,13 +16,15 @@ import de.jeanlucmakiola.floret.identity.FloretExpressiveTheme
*
* The mechanics live in floret-kit's identity module; the look stays here, so a
* sibling app reuses the same theming without inheriting Calendula's palette.
* The Settings screen overrides darkTheme, dynamicColor and typography; the bare
* defaults (used by the crash screen) just follow the system.
* The Settings screen overrides darkTheme, dynamicColor, pureBlack (the
* AMOLED theme, #181) and typography; the bare defaults (used by the crash
* screen) just follow the system.
*/
@Composable
fun CalendulaTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
pureBlack: Boolean = false,
typography: Typography = CalendulaTypography,
content: @Composable () -> Unit,
) {
@@ -31,6 +33,7 @@ fun CalendulaTheme(
darkScheme = CalendulaDarkFallback,
darkTheme = darkTheme,
dynamicColor = dynamicColor,
pureBlack = pureBlack,
typography = typography,
content = content,
)
@@ -0,0 +1,45 @@
package de.jeanlucmakiola.calendula.ui.theme
import android.content.Context
import androidx.annotation.ColorRes
import androidx.core.content.edit
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
/**
* A synchronous copy of the theme choice, for the window backdrop drawn before
* the first Compose frame. The real setting lives in DataStore, which only
* answers asynchronously — too late for onCreate, so a Light, Dark or Black
* override flashed the night-qualifier backdrop on a cold start or a
* recreation (#181). MainActivity mirrors every stored value here and reads it
* back before setContent.
*/
internal object WindowThemeCache {
private const val FILE = "window_theme"
private const val KEY_THEME_MODE = "theme_mode"
/** The last mirrored theme choice, or null before the first mirror. */
fun read(context: Context): ThemeMode? =
context.getSharedPreferences(FILE, Context.MODE_PRIVATE)
.getString(KEY_THEME_MODE, null)
?.let { name -> ThemeMode.entries.firstOrNull { it.name == name } }
fun write(context: Context, mode: ThemeMode) {
val prefs = context.getSharedPreferences(FILE, Context.MODE_PRIVATE)
if (prefs.getString(KEY_THEME_MODE, null) == mode.name) return
prefs.edit { putString(KEY_THEME_MODE, mode.name) }
}
}
/**
* The window backdrop matching this theme choice's Compose background, or null
* for [ThemeMode.SYSTEM], whose night-qualified theme default already matches.
*/
@ColorRes
internal fun ThemeMode.windowBackgroundRes(): Int? = when (this) {
ThemeMode.SYSTEM -> null
ThemeMode.LIGHT -> R.color.window_background_light
ThemeMode.DARK -> R.color.window_background_dark
ThemeMode.BLACK -> R.color.window_background_black
}
@@ -20,12 +20,12 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec
import de.jeanlucmakiola.calendula.ui.common.rememberViewSwitchSpec
import de.jeanlucmakiola.calendula.ui.common.jumpToPage
import de.jeanlucmakiola.calendula.ui.common.pagedHeight
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.drop
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
@@ -1188,7 +1188,7 @@ private fun AllDayStrip(
animatedHeight: (() -> Dp)? = null,
onEventClick: (EventInstance) -> Unit,
) {
val dark = isSystemInDarkTheme()
val dark = LocalFloretDarkTheme.current
Row(
modifier = Modifier
@@ -1295,7 +1295,7 @@ private fun Timeline(
onResize: (TimelineResizeDrop) -> Unit,
) {
val fullViewportHeight = remember(geometry) { intArrayOf(-1) }
val dark = isSystemInDarkTheme()
val dark = LocalFloretDarkTheme.current
val zoom = LocalTimelineZoom.current
val density = LocalDensity.current
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl