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 Makiola
2026-10-14 17:41:34 +02:00
co-authored by makiolaj
parent 0dd7e07aa0
commit cf3c4e96cd
20 changed files with 128 additions and 36 deletions
+3
View File
@@ -13,8 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Tap a phone number in an event to call it.** Phone numbers in an event's title, location and description are now links. Tapping one opens your phone app with the number filled in, ready to call. A conference dial-in like "+1 646 558 8656,,850123#" brings its meeting ID along. Meeting IDs and PINs on their own, dates, times and amounts stay plain text. Long-pressing a field copies it, even when you press on a link ([#339]).
- **Formatted event descriptions read properly.** Descriptions from Outlook, Exchange and Google Calendar are often stored as HTML, which used to show up as a wall of tags. The event view now shows them as formatted text: bold, italics, lists and links work, table rows sit on one line each, and the fonts and colours the sender picked are left out so the text matches the app. Search matches the text you see. The edit form still shows the original, so saving an event never changes its formatting ([#271]).
- **Scroll back to earlier days in the agenda.** Scroll up past today to see earlier days' events, loaded a month at a time. Past days follow the past-events setting: Dim fades them, and with Hide the agenda still starts at today ([#367]).
- **A black theme for AMOLED screens.** Settings → Appearance → Theme now has Black next to System, Light and Dark. It's the dark theme with pure black backgrounds, which saves battery on AMOLED screens and makes event colours stand out more. Day cells and the hour grid stay a shade above black so the grid lines still show, and sheets and menus still stand out from the page. It works with Material You colours too. The home-screen widgets aren't affected ([#181]).
### Fixed
- **Event colours follow the theme you picked in Calendula.** With the app set to Dark on a phone in light mode, or the other way round, events took their colours from the phone's setting, so they could come out too pale or too dark. They now match the app's theme. Starting the app with a theme set in Calendula also no longer flashes the phone's light or dark background first ([#181]).
- **Events on the day the clocks change show at their real time.** In the day, 3-day and week views, events later on the day of a daylight saving change were drawn an hour off, so a 14:45 event showed at 15:45. They now sit at their own time. When the clocks go back, 02:00 to 03:00 happens twice: an event in that hour now stays in the right one when you move, resize or edit it, and the editor and event details add the zone, like CEST or CET, so you can tell the two apart ([#387]).
- **The agenda always starts today.** Coming from the month or day view, the agenda used to start at the day you had open there, so "Next 30 days" could show the 1st to the 30th of the month. It now opens on today with your chosen range. Backing out of a day you opened from the agenda still returns you to where you were ([#367]).
- **Multi-day events dim on the days already behind you.** With past events dimmed, an event running over several days stayed at full strength on its earlier days until it ended. Those days now dim in the agenda, month, week and day views ([#367]).
@@ -1867,3 +1869,4 @@ automatically, with zero telemetry and no internet permission.
[#339]: https://codeberg.org/jlmakiola/calendula/issues/339
[#387]: https://codeberg.org/jlmakiola/calendula/issues/387
[#271]: https://codeberg.org/jlmakiola/calendula/issues/271
[#181]: https://codeberg.org/jlmakiola/calendula/issues/181
@@ -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
+1 -1
View File
@@ -2,5 +2,5 @@
<!-- Dark-scheme window backdrop, matching the Compose dark background/surface
(#101316) so activity recreation (e.g. language switch) doesn't flash a
lighter grey. See values/colors.xml. -->
<color name="window_background">#FF101316</color>
<color name="window_background">@color/window_background_dark</color>
</resources>
+6 -1
View File
@@ -6,5 +6,10 @@
<!-- Window backdrop shown during activity recreation (e.g. on a language
switch). Matches the Compose light scheme background/surface so the
recreation is seamless; overridden for dark in values-night. -->
<color name="window_background">#FFFBFCFE</color>
<color name="window_background">@color/window_background_light</color>
<!-- The same backdrops pinned for an in-app theme override, which the night
qualifier can't see. MainActivity applies one before the first frame. -->
<color name="window_background_light">#FFFBFCFE</color>
<color name="window_background_dark">#FF101316</color>
<color name="window_background_black">#FF000000</color>
</resources>
+2
View File
@@ -454,6 +454,8 @@
<string name="settings_theme_system">System</string>
<string name="settings_theme_light">Light</string>
<string name="settings_theme_dark">Dark</string>
<string name="settings_theme_black">Black</string>
<string name="settings_theme_black_summary">Dark with pure black backgrounds, for AMOLED screens</string>
<string name="settings_theme_hint">Whether the app is light or dark. The choice applies immediately.</string>
<!-- %1$s is the theme the system currently resolves to, e.g. "Dark". -->
<string name="settings_theme_system_summary">Currently %1$s</string>
@@ -47,6 +47,13 @@ class SettingsPrefsTest {
assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.DARK)
}
@Test
fun `black theme mode round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.setThemeMode(ThemeMode.BLACK)
assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.BLACK)
}
@Test
fun `dynamic colour round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))