From cf3c4e96cd222d58babdc8c7c48d15df450994b7 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 14 Oct 2026 17:41:34 +0200 Subject: [PATCH] Add a black theme for AMOLED screens (#181) (#399) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/399 --- CHANGELOG.md | 3 ++ .../jeanlucmakiola/calendula/MainActivity.kt | 27 ++++++++++- .../calendula/data/prefs/SettingsPrefs.kt | 7 ++- .../calendula/ui/agenda/AgendaRows.kt | 4 +- .../calendula/ui/calendars/CalendarsScreen.kt | 6 +-- .../calendula/ui/common/CalendarColors.kt | 4 +- .../calendula/ui/common/TimelineDrag.kt | 4 +- .../calendula/ui/detail/EventDetailScreen.kt | 4 +- .../calendula/ui/edit/EventEditScreen.kt | 6 +-- .../calendula/ui/month/MonthScreen.kt | 8 ++-- .../calendula/ui/search/SearchScreen.kt | 4 +- .../ui/settings/AppearanceSettings.kt | 9 ++-- .../calendula/ui/theme/Theme.kt | 7 ++- .../calendula/ui/theme/WindowTheme.kt | 45 +++++++++++++++++++ .../calendula/ui/week/WeekScreen.kt | 6 +-- app/src/main/res/values-night/colors.xml | 2 +- app/src/main/res/values/colors.xml | 7 ++- app/src/main/res/values/strings.xml | 2 + .../calendula/data/prefs/SettingsPrefsTest.kt | 7 +++ floret-kit | 2 +- 20 files changed, 128 insertions(+), 36 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/WindowTheme.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index efadd073..12c952a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt index ef240199..5f00251a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt @@ -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()) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index 25ced1ce..0fad52fd 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -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; diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt index 31ac272e..34ab3681 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt @@ -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( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt index 1c3f66f4..a99bf791 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt @@ -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( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarColors.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarColors.kt index b37b64ad..87122c4a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarColors.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarColors.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt index 2b902375..e4377bd7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt @@ -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() diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index 952f70f8..0d99c05e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -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) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 3e170fd2..ea31c6f5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -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, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 07830045..2e778c2d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -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() diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt index 84539050..2c456c02 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt @@ -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. diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppearanceSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppearanceSettings.kt index 5774e5a8..6febcc4d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppearanceSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppearanceSettings.kt @@ -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 }, ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Theme.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Theme.kt index 5e56f5b2..e7908610 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Theme.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Theme.kt @@ -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, ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/WindowTheme.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/WindowTheme.kt new file mode 100644 index 00000000..ef3c603b --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/WindowTheme.kt @@ -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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index d7b17527..85d3799c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -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 diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml index 15fc9905..db4932f2 100644 --- a/app/src/main/res/values-night/colors.xml +++ b/app/src/main/res/values-night/colors.xml @@ -2,5 +2,5 @@ - #FF101316 + @color/window_background_dark diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 3435ae81..f941fbd3 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -6,5 +6,10 @@ - #FFFBFCFE + @color/window_background_light + + #FFFBFCFE + #FF101316 + #FF000000 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8a49eb1a..95bdd57a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -454,6 +454,8 @@ System Light Dark + Black + Dark with pure black backgrounds, for AMOLED screens Whether the app is light or dark. The choice applies immediately. Currently %1$s diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt index 42fc64f6..ad38ee34 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt @@ -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)) diff --git a/floret-kit b/floret-kit index 8b33635a..b1905dac 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 8b33635a852d404cd72d2483dd5bdc4926f13276 +Subproject commit b1905dac00e554fcb80eb73049e27c2e9b9553f7