Compare commits

...
2 Commits
Author SHA1 Message Date
95ad3a30cf Release v2.24.1 (#504)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Renovate / renovate (push) Successful in 1m20s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 19m27s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) In progress
- Agenda widget: Cards / Classic style option, opacity slider for all Solid/Tinted widgets (#489)
- Month widget fits events into short week rows (#491)
- Week numbers follow the locale's rules and the row's first day (#493)
- Readable dimmed events in dark widgets, pinned Light/Dark theme matches system colours (#475)
- Dropped unused WorkManager permissions: network state, wake lock, foreground service (#448)
- Weblate translations (#434), incl. the Arabic string from after the 2.24.0 squash
- Version 2.24.1 (2240199), CHANGELOG, What's New in all store locales

#489 deviates from the issue: the classic look comes back as a style option instead of replacing the new design.

Closes #489
Closes #491
Closes #493
Closes #475
Closes #448

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Co-authored-by: Sup99999 <alelmnour1o234@proton.me>
Co-authored-by: Anonymous <noreply@weblate.org>
Co-authored-by: Louis Leblanc <sandraev@posteo.dk>
Co-authored-by: Dmitri Gabinski <dkgabi@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/504
2026-11-13 11:17:48 +01:00
Jean-Luc Makiola 8ce4831efa Translations update from Weblate (#434)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 12s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 2m59s
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/434
2026-11-13 11:07:42 +01:00
43 changed files with 937 additions and 155 deletions

No files matched your search

+21
View File
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [2.24.1] — 2026-11-12
### Added
- **A classic style for the agenda widget.** Its settings now have a Style option: Cards keeps the new look, Classic brings back the 2.23 layout with a line per day and a colour stripe beside each event, under the current header. Dates in another year show the year. Classic has an Invert text colour switch, so its text stays readable on light and dark wallpapers ([#489]).
- **Widget opacity.** Every widget with a Solid or Tinted background has an Opacity slider from 20 to 100%. Below 50%, the card-style widgets back their content like a Transparent widget so it stays readable ([#489]).
### Fixed
- **Week numbers match your region.** With weeks starting on Sunday, the month and week views showed every week number one too low. Rows are now numbered by your region's rules, so ISO regions such as Germany are unchanged ([#493]).
- **The month widget shows events at every height.** At some sizes a week row had room for a little more than the date but not a full line of events, and drew none. It now fits a slimmer line there, and a row with a single line shows "+N" on days with more events than fit ([#491]).
- **Dimmed past events in dark widgets are readable again.** They used to turn pastel with white text on top ([#475]).
- **A widget set to Light or Dark matches the system colours.** With Material You, the + button and today's date could look different from a widget that follows the app ([#475]).
### Changed
- **Fewer permissions.** Calendula no longer asks for network state, wake lock or foreground service access, which a library added without the app needing them ([#448]).
- Translation updates from [Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
## [2.24.0] — 2026-11-06
### Added
@@ -1952,3 +1968,8 @@ automatically, with zero telemetry and no internet permission.
[#473]: https://codeberg.org/jlmakiola/calendula/issues/473
[#476]: https://codeberg.org/jlmakiola/calendula/issues/476
[#483]: https://codeberg.org/jlmakiola/calendula/issues/483
[#448]: https://codeberg.org/jlmakiola/calendula/issues/448
[#475]: https://codeberg.org/jlmakiola/calendula/issues/475
[#489]: https://codeberg.org/jlmakiola/calendula/issues/489
[#491]: https://codeberg.org/jlmakiola/calendula/issues/491
[#493]: https://codeberg.org/jlmakiola/calendula/issues/493
+2 -2
View File
@@ -32,8 +32,8 @@ android {
// (up to 2.22.3: 2.22.3 -> 22203; from 2.22.4: 2.23.0-beta.1 -> 2230001,
// 2.22.4 -> 2220499), and CI fails if the committed one doesn't match.
// See docs/RELEASING.md.
versionCode = 2240099
versionName = "2.24.0"
versionCode = 2240199
versionName = "2.24.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
+5
View File
@@ -41,6 +41,11 @@
-->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- WorkManager declares these for network constraints and foreground workers, neither of which the app uses (#448). -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" tools:node="remove" />
<uses-permission android:name="android.permission.WAKE_LOCK" tools:node="remove" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" tools:node="remove" />
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
returns null and the calendar manager's per-account "manage" button can't
open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER
@@ -0,0 +1,21 @@
package de.jeanlucmakiola.calendula.ui.common
import android.content.res.Resources
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import de.jeanlucmakiola.floret.time.rowWeekNumber
import kotlinx.datetime.LocalDate
import kotlinx.datetime.toJavaLocalDate
import java.time.temporal.WeekFields
/**
* The week number of the grid row starting on [rowStart], numbered by the
* device region's rules (#493). The region, not the app language: an English
* UI in the Netherlands still numbers weeks the ISO way. Shared by the Month
* gutter and the Week badge so the two always agree.
*/
@Composable
internal fun rememberRowWeekNumber(rowStart: LocalDate): Int {
val fields = remember { WeekFields.of(Resources.getSystem().configuration.locales[0]) }
return remember(rowStart, fields) { rowStart.toJavaLocalDate().rowWeekNumber(fields) }
}
@@ -187,7 +187,6 @@ import de.jeanlucmakiola.floret.identity.itemEnter
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.floret.time.isoWeekNumber
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
@@ -205,6 +204,7 @@ import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
import kotlinx.datetime.toJavaMonth
import kotlinx.datetime.yearMonth
import de.jeanlucmakiola.calendula.ui.common.rememberRowWeekNumber
private val LocalTodayPulse = staticCompositionLocalOf { 0 }
@@ -2701,13 +2701,13 @@ private fun monthChipDragModifier(
/**
* Left-gutter calendar-week cell (#25): a full-height tonal pill mirroring the
* day cells' geometry, set apart by the secondaryContainer tint (matching the
* Week view's badge), with the ISO week number centred like a day number. The
* week is computed on the row's first day — the same basis as the Week view — so
* the two agree.
* Week view's badge), with the week number centred like a day number. It is
* numbered by the device region from the row's mid-week day, as in the Week
* view, so it is right for any first weekday (#493).
*/
@Composable
private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier) {
val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() }
val weekNumber = rememberRowWeekNumber(weekStart)
val label = stringResource(R.string.week_number_label)
val openLabel = stringResource(R.string.month_open_week, weekNumber)
val onOpenWeek = LocalOpenWeek.current
@@ -209,7 +209,6 @@ import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET
import de.jeanlucmakiola.calendula.ui.common.hourCellBlockInset
import de.jeanlucmakiola.calendula.ui.common.hourGridCells
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
import de.jeanlucmakiola.floret.time.isoWeekNumber
import kotlinx.coroutines.launch
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
@@ -221,6 +220,7 @@ import kotlinx.datetime.toLocalDateTime
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
import kotlinx.datetime.yearMonth
import de.jeanlucmakiola.calendula.ui.common.rememberRowWeekNumber
/** One lane of the all-day strip, sized to a trimmed bar line (#190). */
private val ALL_DAY_ROW_HEIGHT = 20.dp
@@ -1137,7 +1137,7 @@ private fun WeekDayHeader(
*/
@Composable
private fun WeekNumberCell(weekStart: LocalDate, showBadge: Boolean, modifier: Modifier = Modifier) {
val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() }
val weekNumber = rememberRowWeekNumber(weekStart)
Column(
modifier = modifier
.fillMaxWidth()
@@ -4,6 +4,7 @@ import android.content.Context
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.glance.GlanceId
@@ -26,14 +27,22 @@ enum class WidgetTheme { FOLLOW_APP, LIGHT, DARK }
data class WidgetAppearance(
val background: WidgetBackground = WidgetBackground.TINTED,
val theme: WidgetTheme = WidgetTheme.FOLLOW_APP,
/** How opaque a Solid or Tinted fill is (#489). */
val opacity: Float = 1f,
)
/** The agenda widget's look: the 2.24 day cards, or v2.23's plain day headers and stripes (#489). */
enum class AgendaWidgetStyle { CARDS, CLASSIC }
/** What the agenda widget lists, per placed widget. */
data class AgendaWidgetOptions(
val range: AgendaRange,
val textSize: WidgetSize,
val showToday: Boolean,
val pastDisplay: PastEventDisplay,
val style: AgendaWidgetStyle = AgendaWidgetStyle.CARDS,
/** Classic style only: draw the text in the opposite theme's colours, for a faded fill over the wallpaper. */
val invertText: Boolean = false,
)
internal fun WidgetTheme.resolve(app: ThemeMode): WidgetThemeMode = when (this) {
@@ -48,6 +57,9 @@ internal fun WidgetTheme.resolve(app: ThemeMode): WidgetThemeMode = when (this)
private val BackgroundKey = stringPreferencesKey("cfg_background")
private val ThemeKey = stringPreferencesKey("cfg_theme")
private val OpacityKey = floatPreferencesKey("cfg_opacity")
private val AgendaStyleKey = stringPreferencesKey("cfg_agenda_style")
private val InvertTextKey = booleanPreferencesKey("cfg_invert_text")
private val AgendaRangeKey = stringPreferencesKey("cfg_agenda_range")
private val TextSizeKey = stringPreferencesKey("cfg_text_size")
private val ShowTodayKey = booleanPreferencesKey("cfg_show_today")
@@ -58,6 +70,7 @@ internal val Preferences.widgetAppearance: WidgetAppearance
get() = WidgetAppearance(
background = this[BackgroundKey].toEnum(WidgetBackground.TINTED),
theme = this[ThemeKey].toEnum(WidgetTheme.FOLLOW_APP),
opacity = this[OpacityKey] ?: 1f,
)
/**
@@ -70,6 +83,8 @@ internal fun Preferences.agendaWidgetOptions(defaults: AgendaWidgetOptions) = Ag
textSize = this[TextSizeKey].toEnum(defaults.textSize),
showToday = this[ShowTodayKey] ?: defaults.showToday,
pastDisplay = this[PastDisplayKey].toEnum(defaults.pastDisplay),
style = this[AgendaStyleKey].toEnum(defaults.style),
invertText = this[InvertTextKey] ?: defaults.invertText,
)
/** Whether this widget has agenda options of its own, rather than following the app's. */
@@ -78,6 +93,7 @@ internal val Preferences.hasAgendaOptions: Boolean get() = this[AgendaRangeKey]
internal fun MutablePreferences.putAppearance(appearance: WidgetAppearance) {
this[BackgroundKey] = appearance.background.name
this[ThemeKey] = appearance.theme.name
this[OpacityKey] = appearance.opacity
}
internal fun MutablePreferences.putAgendaOptions(options: AgendaWidgetOptions) {
@@ -85,6 +101,8 @@ internal fun MutablePreferences.putAgendaOptions(options: AgendaWidgetOptions) {
this[TextSizeKey] = options.textSize.name
this[ShowTodayKey] = options.showToday
this[PastDisplayKey] = options.pastDisplay.name
this[AgendaStyleKey] = options.style.name
this[InvertTextKey] = options.invertText
}
/**
@@ -126,6 +126,8 @@ sealed interface AgendaWidgetData {
/** "Always show today" (#35): anchor today at the top even with no events left. */
val showToday: Boolean,
val textSize: WidgetSize,
val style: AgendaWidgetStyle = AgendaWidgetStyle.CARDS,
val invertText: Boolean = false,
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
val now: Instant,
) : AgendaWidgetData
@@ -184,6 +186,8 @@ internal suspend fun Context.loadAgendaWidgetData(
pastDisplay = options.pastDisplay,
showToday = options.showToday,
textSize = options.textSize,
style = options.style,
invertText = options.invertText,
now = Clock.System.now(),
)
}
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.widget
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
@@ -18,7 +19,6 @@ import androidx.glance.layout.padding
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.isCrossedOut
@@ -26,20 +26,33 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback
import de.jeanlucmakiola.calendula.widget.agenda.metricsFor
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.glance.WidgetGroupedRow
import de.jeanlucmakiola.floret.glance.themedColor
/** A past event's fill when dimmed: fainter than the app's whole-row dim, so its text can stay readable (#90). */
internal const val WidgetDimFillAlpha = 0.3f
/** How much of a past event's tone stays in its dimmed fill (#90). */
private const val WidgetDimFillAlpha = 0.3f
/** A dimmed event's ink, held above its fill. */
private const val WidgetDimInkAlpha = 0.65f
/** Ink for an event on [fill], picked against the opaque tone and faded when the fill is dimmed. */
internal fun widgetEventInk(fill: Color, alpha: Float): Color =
eventInk(fill.copy(alpha = 1f), if (fill.alpha < 1f) alpha * WidgetDimInkAlpha else alpha)
/**
* An event's fill in the light or [dark] theme. A dimmed one is its tone faded
* into that theme's surface, kept opaque: a translucent tint lands on the row
* shape's white, not on the widget, and its ink would be picked for the wrong tone (#475).
*/
internal fun widgetEventFill(rawArgb: Int, dark: Boolean, soften: Boolean, dimmed: Boolean): Color {
val tone = eventFill(rawArgb, dark, soften)
if (!dimmed) return tone
val surface = if (dark) CalendulaDarkFallback.surface else Color.White
return tone.copy(alpha = WidgetDimFillAlpha).compositeOver(surface)
}
/** Ink for an event on [fill], faded when the row is [dimmed]. */
internal fun widgetEventInk(fill: Color, alpha: Float, dimmed: Boolean): Color =
eventInk(fill, if (dimmed) alpha * WidgetDimInkAlpha else alpha)
/** Text and row sizes of a compact event list, from a widget's [WidgetSize]. */
internal data class EventRowMetrics(val title: TextUnit, val time: TextUnit, val rowHeight: Dp, val message: TextUnit)
@@ -72,9 +85,8 @@ internal fun WidgetEventRow(
quiet: Boolean = false,
) {
val context = LocalContext.current
fun tone(dark: Boolean): Color = eventFill(event.color, dark, soften).let { if (dimmed) it.copy(alpha = WidgetDimFillAlpha) else it }
val day = tone(dark = false)
val night = tone(dark = true)
val day = widgetEventFill(event.color, dark = false, soften, dimmed)
val night = widgetEventFill(event.color, dark = true, soften, dimmed)
val card = widgetCardColor()
WidgetGroupedRow(position = position, color = if (quiet) card else themedColor(day = day, night = night)) {
Column(
@@ -84,13 +96,7 @@ internal fun WidgetEventRow(
.padding(horizontal = 12.dp)
.clickable(
actionStartActivity(
MainActivity.openEventIntent(
context = context,
eventId = event.eventId,
beginMillis = event.start.toEpochMilliseconds(),
endMillis = event.end.toEpochMilliseconds(),
source = source,
),
event.openIntent(context, source),
),
),
verticalAlignment = Alignment.CenterVertically,
@@ -98,12 +104,12 @@ internal fun WidgetEventRow(
val titleInk = if (quiet) {
GlanceTheme.colors.onSurface
} else {
themedColor(day = widgetEventInk(day, 1f), night = widgetEventInk(night, 1f))
themedColor(day = widgetEventInk(day, 1f, dimmed), night = widgetEventInk(night, 1f, dimmed))
}
val timeInk = if (quiet) {
GlanceTheme.colors.onSurfaceVariant
} else {
themedColor(day = widgetEventInk(day, SECONDARY_INK_ALPHA), night = widgetEventInk(night, SECONDARY_INK_ALPHA))
themedColor(day = widgetEventInk(day, SECONDARY_INK_ALPHA, dimmed), night = widgetEventInk(night, SECONDARY_INK_ALPHA, dimmed))
}
Text(
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
@@ -26,7 +26,7 @@ import de.jeanlucmakiola.calendula.widget.split.MonthDayWidget
import de.jeanlucmakiola.calendula.widget.split.MonthDayWidgetReceiver
/** A content option a widget's config screen offers, besides background and theme. */
enum class WidgetOption { RANGE, TEXT_SIZE, PAST_DISPLAY, SHOW_TODAY, CALENDARS }
enum class WidgetOption { STYLE, RANGE, TEXT_SIZE, PAST_DISPLAY, SHOW_TODAY, CALENDARS }
/**
* Every widget the app ships, told apart by their receiver: what redraws,
@@ -42,7 +42,14 @@ enum class WidgetKind(
AGENDA(
AgendaWidgetReceiver::class.java,
R.string.widget_agenda_label,
setOf(WidgetOption.RANGE, WidgetOption.TEXT_SIZE, WidgetOption.PAST_DISPLAY, WidgetOption.SHOW_TODAY, WidgetOption.CALENDARS),
setOf(
WidgetOption.STYLE,
WidgetOption.RANGE,
WidgetOption.TEXT_SIZE,
WidgetOption.PAST_DISPLAY,
WidgetOption.SHOW_TODAY,
WidgetOption.CALENDARS,
),
DpSize(260.dp, 280.dp),
),
MONTH(
@@ -19,6 +19,9 @@ import kotlinx.datetime.toJavaDayOfWeek
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import androidx.glance.appwidget.action.actionStartActivity
/** Cards sit one tone off the root fill, and stay opaque over a wallpaper (#292). */
@Composable
@@ -27,6 +30,19 @@ internal fun widgetCardColor(): ColorProvider = when (LocalWidgetBackground.curr
WidgetBackground.TINTED, WidgetBackground.TRANSPARENT -> GlanceTheme.colors.widgetBackground
}
/** Opens this occurrence in the app, backing out to [source]. */
internal fun EventInstance.openIntent(context: Context, source: CalendarView) = MainActivity.openEventIntent(
context = context,
eventId = eventId,
beginMillis = start.toEpochMilliseconds(),
endMillis = end.toEpochMilliseconds(),
source = source,
)
/** Opens [date] in the app's agenda. */
internal fun openDayAction(context: Context, date: LocalDate) =
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Agenda))
/** An event's time on [day]: "All day", "Starts 14:00", "Ends 9:00" or "14:00 – 15:30". */
internal fun widgetTimeLabel(context: Context, event: EventInstance, day: LocalDate, is24Hour: Boolean): String =
when (val label = agendaTimeLabel(event, day, systemZone())) {
@@ -34,7 +34,7 @@ fun CalendulaGlanceTheme(mode: WidgetThemeMode, content: @Composable () -> Unit)
@Composable
internal fun CalendulaWidgetRoot(appearance: WidgetAppearance, appTheme: ThemeMode, content: @Composable () -> Unit) {
CalendulaGlanceTheme(appearance.theme.resolve(appTheme)) {
WidgetSurface(background = appearance.background, content = content)
WidgetSurface(background = appearance.background, opacity = appearance.opacity, content = content)
}
}
@@ -12,6 +12,15 @@ internal val ROW_H_PAD = 12.dp
/** Space between the date-pill gutter and a day's card. */
internal val GUTTER_GAP = 6.dp
/** The classic style's row inset, colour stripe and the gap after it (#489). */
internal val CLASSIC_ROW_H_PAD = 4.dp
internal val STRIPE_WIDTH = 5.dp
internal val STRIPE_GAP = 10.dp
/** The stripe and its gap, and where the classic style's event text starts, so its placeholder lines up with the titles. */
internal val STRIPE_INSET = STRIPE_WIDTH + STRIPE_GAP
internal val TEXT_INDENT = CLASSIC_ROW_H_PAD + STRIPE_INSET
/**
* Every size the agenda widget varies by [WidgetSize]. Values that genuinely
* shouldn't grow (the horizontal row constants above, corner radii) stay
@@ -29,6 +38,8 @@ internal data class AgendaMetrics(
val rowVPad: Dp, // event row vertical padding
val dayGap: Dp, // space between two days' cards
val monthLabel: TextUnit, // month name above a new month's first day
val dayHeader: TextUnit, // classic style's day label ("Today · …")
val dayHeaderTopPad: Dp, // classic style's space above a day label
) {
/** One event row's height, shared by the date pill beside it and the placeholder row. */
val rowHeight: Dp get() = contentH + rowVPad * 2
@@ -61,6 +72,8 @@ private val SMALL_METRICS = AgendaMetrics(
rowVPad = 4.dp,
dayGap = 8.dp,
monthLabel = 12.sp,
dayHeader = 13.sp,
dayHeaderTopPad = 10.dp,
)
private val MEDIUM_METRICS = AgendaMetrics(
@@ -75,6 +88,8 @@ private val MEDIUM_METRICS = AgendaMetrics(
rowVPad = 5.dp,
dayGap = 9.dp,
monthLabel = 14.sp,
dayHeader = 14.sp, // M3 Title Small
dayHeaderTopPad = 11.dp,
)
private val LARGE_METRICS = AgendaMetrics(
@@ -89,6 +104,8 @@ private val LARGE_METRICS = AgendaMetrics(
rowVPad = 6.dp,
dayGap = 10.dp,
monthLabel = 14.sp,
dayHeader = 16.sp, // M3 Title Medium
dayHeaderTopPad = 12.dp,
)
private val EXTRA_LARGE_METRICS = AgendaMetrics(
@@ -103,6 +120,8 @@ private val EXTRA_LARGE_METRICS = AgendaMetrics(
rowVPad = 8.dp,
dayGap = 12.dp,
monthLabel = 16.sp,
dayHeader = 18.sp, // †
dayHeaderTopPad = 14.dp,
)
/** Indexed by [WidgetSize.ordinal] so lookup allocates nothing per recomposition. */
@@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
@@ -55,8 +56,15 @@ import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.widget.WidgetDimFillAlpha
import de.jeanlucmakiola.calendula.widget.widgetEventFill
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.widget.AgendaWidgetStyle
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
import de.jeanlucmakiola.floret.glance.LocalWidgetSeeThrough
import androidx.compose.runtime.CompositionLocalProvider
import de.jeanlucmakiola.floret.glance.WidgetBackground
import de.jeanlucmakiola.floret.glance.withAlpha
import de.jeanlucmakiola.calendula.widget.widgetEventInk
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
@@ -65,6 +73,8 @@ import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.widgetCardColor
import de.jeanlucmakiola.calendula.widget.widgetTimeLabel
import de.jeanlucmakiola.calendula.widget.openIntent
import de.jeanlucmakiola.calendula.widget.openDayAction
import de.jeanlucmakiola.calendula.widget.loadAgendaWidget
import de.jeanlucmakiola.calendula.widget.widgetAppearance
import de.jeanlucmakiola.floret.components.Position
@@ -87,7 +97,8 @@ import kotlinx.datetime.toJavaDayOfWeek
/**
* "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days
* of events, each day one card of grouped rows with its date pill beside it.
* of events: each day one card of grouped rows with its date pill beside it, or
* in the classic style a label line over plain colour-stripe rows (#489).
* Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
*/
class AgendaWidget : GlanceAppWidget() {
@@ -150,10 +161,10 @@ private const val PREVIEW_ROWS = 10
* user scrolling a home-screen widget past a hundred rows is not a case worth
* risking a failed update for.
*/
private const val MAX_AGENDA_ROWS = 100
internal const val MAX_AGENDA_ROWS = 100
/** Flat row model: one [LazyColumn] item per event or placeholder, a day's first one carrying its date pill. */
private sealed interface AgendaRow {
internal sealed interface AgendaRow {
val date: LocalDate
data class Event(override val date: LocalDate, val event: EventInstance) : AgendaRow
@@ -168,16 +179,21 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, preview: Boolean) {
val size = (data as? AgendaWidgetData.Ready)?.textSize ?: WidgetSize.SMALL
val fontScale = androidx.glance.LocalContext.current.resources.configuration.fontScale
val metrics = metricsFor(size).scaledForFont(fontScale)
Column(
modifier = GlanceModifier
.fillMaxSize()
.padding(horizontal = 8.dp, vertical = 6.dp),
) {
AgendaHeader()
Spacer(GlanceModifier.height(6.dp))
when (data) {
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics)
is AgendaWidgetData.Ready -> AgendaRows(data = data, metrics = metrics, preview = preview)
// The classic style adds no backings for a faded fill, only on a transparent one (#489).
val classic = (data as? AgendaWidgetData.Ready)?.style == AgendaWidgetStyle.CLASSIC
val seeThrough = if (classic) LocalWidgetBackground.current == WidgetBackground.TRANSPARENT else LocalWidgetSeeThrough.current
CompositionLocalProvider(LocalWidgetSeeThrough provides seeThrough) {
Column(
modifier = GlanceModifier
.fillMaxSize()
.padding(horizontal = 8.dp, vertical = 6.dp),
) {
AgendaHeader()
Spacer(GlanceModifier.height(6.dp))
when (data) {
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics)
is AgendaWidgetData.Ready -> AgendaRows(data = data, metrics = metrics, preview = preview)
}
}
}
}
@@ -219,60 +235,248 @@ private fun AgendaRows(
day.events.forEach { add(AgendaRow.Event(day.date, it)) }
}
}
}.take(MAX_AGENDA_ROWS)
// Each day is one card of grouped rows (#71), with its date pill
// beside the first row.
val positions = dayPositions(rows)
val cardColor = widgetCardColor()
val item: @Composable (Int) -> Unit = { index ->
val row = rows[index]
val position = positions[index]
val first = position == Position.Top || position == Position.Alone
val dayRow: @Composable (GlanceModifier) -> Unit = { modifier ->
Row(modifier = modifier.fillMaxWidth()) {
Box(modifier = GlanceModifier.width(metrics.pillWidth)) {
if (first) DatePill(row.date, data.today, cardColor, metrics)
}
Spacer(GlanceModifier.width(GUTTER_GAP))
Box(modifier = GlanceModifier.defaultWeight()) {
val rowColor = when (row) {
is AgendaRow.Event -> row.fill(data).let { themedColor(day = it.day, night = it.night) }
is AgendaRow.Placeholder -> cardColor
}
WidgetGroupedRow(position = position, color = rowColor) {
AgendaRowContent(row, data, metrics)
}
}
}
}
val gap = GlanceModifier.padding(top = if (first && index > 0) metrics.dayGap else 0.dp)
// The pills carry no month, so a new month gets its name above its first day.
if (first && index > 0 && row.date.month != rows[index - 1].date.month) {
Column(modifier = gap.fillMaxWidth()) {
MonthLabel(row.date, data.today, metrics)
dayRow(GlanceModifier)
}
} else {
dayRow(gap)
}
}
if (preview) {
Column(modifier = GlanceModifier.fillMaxSize()) {
repeat(minOf(rows.size, PREVIEW_ROWS)) { item(it) }
}
} else {
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
items(rows.size) { item(it) }
}
when (data.style) {
AgendaWidgetStyle.CARDS -> CardRows(rows, data, metrics, preview)
AgendaWidgetStyle.CLASSIC -> ClassicRows(rows, data, metrics, preview)
}
}
}
/** Where each row sits among the rows of its day, for the grouped-card shapes. */
private fun dayPositions(rows: List<AgendaRow>): List<Position> = buildList {
/** Each day is one card of grouped rows (#71), with its date pill beside the first row. */
@Composable
private fun CardRows(allRows: List<AgendaRow>, data: AgendaWidgetData.Ready, metrics: AgendaMetrics, preview: Boolean) {
val rows = allRows.take(MAX_AGENDA_ROWS)
val positions = dayPositions(rows.map { it.date })
val cardColor = widgetCardColor()
val item: @Composable (Int) -> Unit = { index ->
val row = rows[index]
val position = positions[index]
val first = position == Position.Top || position == Position.Alone
val dayRow: @Composable (GlanceModifier) -> Unit = { modifier ->
Row(modifier = modifier.fillMaxWidth()) {
Box(modifier = GlanceModifier.width(metrics.pillWidth)) {
if (first) DatePill(row.date, data.today, cardColor, metrics)
}
Spacer(GlanceModifier.width(GUTTER_GAP))
Box(modifier = GlanceModifier.defaultWeight()) {
val rowColor = when (row) {
is AgendaRow.Event -> row.fill(data).let { themedColor(day = it.day, night = it.night) }
is AgendaRow.Placeholder -> cardColor
}
WidgetGroupedRow(position = position, color = rowColor) {
AgendaRowContent(row, data, metrics)
}
}
}
}
val gap = GlanceModifier.padding(top = if (first && index > 0) metrics.dayGap else 0.dp)
// The pills carry no month, so a new month gets its name above its first day.
if (first && index > 0 && row.date.month != rows[index - 1].date.month) {
Column(modifier = gap.fillMaxWidth()) {
MonthLabel(row.date, data.today, metrics)
dayRow(GlanceModifier)
}
} else {
dayRow(gap)
}
}
RowList(rows.size, preview, item)
}
/** [count] rows of [item]: a plain column in a [preview], else a [LazyColumn]. */
@Composable
private fun RowList(count: Int, preview: Boolean, item: @Composable (Int) -> Unit) {
if (preview) {
Column(modifier = GlanceModifier.fillMaxSize()) {
repeat(minOf(count, PREVIEW_ROWS)) { item(it) }
}
} else {
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
items(count) { item(it) }
}
}
}
/** A classic list entry: a day's label line, or one of its rows. */
internal sealed interface ClassicItem {
val date: LocalDate
data class Header(override val date: LocalDate) : ClassicItem
data class Entry(val row: AgendaRow) : ClassicItem {
override val date: LocalDate get() = row.date
}
}
/**
* [rows] with a label before each day's first, held to [limit] items in all,
* headers included; a label the cut leaves with nothing under it is dropped.
*/
internal fun classicItems(rows: List<AgendaRow>, limit: Int = MAX_AGENDA_ROWS): List<ClassicItem> = buildList {
rows.forEachIndexed { index, row ->
if (index == 0 || row.date != rows[index - 1].date) add(ClassicItem.Header(row.date))
add(ClassicItem.Entry(row))
}
}.take(limit).dropLastWhile { it is ClassicItem.Header }
/**
* v2.23's look (#489): a label line per day over plain rows with a colour
* stripe. On a transparent root each day sits on its own backing card, so the
* text stays readable over the wallpaper; a faded fill is left to the opacity
* and "Invert text colour" options.
*/
@Composable
private fun ClassicRows(rows: List<AgendaRow>, data: AgendaWidgetData.Ready, metrics: AgendaMetrics, preview: Boolean) {
val items = classicItems(rows, if (preview) PREVIEW_ROWS else MAX_AGENDA_ROWS)
val backed = LocalWidgetBackground.current == WidgetBackground.TRANSPARENT
val ink = classicInk(inverted = data.invertText && !backed)
val positions = if (backed) dayPositions(items.map { it.date }) else emptyList()
val cardColor = widgetCardColor()
val item: @Composable (Int) -> Unit = { index ->
val entry = items[index]
val content: @Composable () -> Unit = {
when (entry) {
is ClassicItem.Header -> DayHeaderRow(
date = entry.date,
today = data.today,
startPad = if (backed) CLASSIC_CARD_H_PAD else 8.dp,
topPad = when {
backed -> CLASSIC_CARD_TOP_PAD
index == 0 -> 0.dp
else -> metrics.dayHeaderTopPad
},
ink = ink,
metrics = metrics,
)
is ClassicItem.Entry -> when (val row = entry.row) {
is AgendaRow.Placeholder -> ClassicPlaceholderRow(row.date, backed, ink, metrics)
is AgendaRow.Event -> ClassicEventRow(row, data, backed, ink, metrics)
}
}
}
if (backed) {
val gap = if (entry is ClassicItem.Header && index > 0) metrics.dayGap else 0.dp
WidgetGroupedRow(
position = positions[index],
modifier = GlanceModifier.padding(top = gap),
color = cardColor,
gapBelow = false,
content = content,
)
} else {
content()
}
}
RowList(items.size, preview, item)
}
/** The classic style's text colours: the theme's, or its inverse for the "Invert text colour" option. */
private data class ClassicInk(val title: ColorProvider, val secondary: ColorProvider, val today: ColorProvider)
@Composable
private fun classicInk(inverted: Boolean): ClassicInk = with(GlanceTheme.colors) {
if (inverted) {
ClassicInk(inverseOnSurface, inverseOnSurface.withAlpha(INVERTED_SECONDARY_INK), inversePrimary)
} else {
ClassicInk(onSurface, onSurfaceVariant, primary)
}
}
/** A classic day label, "Today · Wed, 17 Jun"; opens that day in the app. */
@Composable
private fun DayHeaderRow(date: LocalDate, today: LocalDate, startPad: Dp, topPad: Dp, ink: ClassicInk, metrics: AgendaMetrics) {
val context = androidx.glance.LocalContext.current
Text(
text = agendaDayLabel(context, date, today),
maxLines = 1,
style = TextStyle(
color = if (date == today) ink.today else ink.secondary,
fontSize = metrics.dayHeader,
fontWeight = FontWeight.Medium,
),
modifier = GlanceModifier
.fillMaxWidth()
.padding(start = startPad, end = 8.dp, top = topPad, bottom = metrics.rowVPad)
.clickable(openDayAction(context, date)),
)
}
/** "No more events today" as a muted line under an anchored, event-less today (#35), indented to the titles. */
@Composable
private fun ClassicPlaceholderRow(date: LocalDate, backed: Boolean, ink: ClassicInk, metrics: AgendaMetrics) {
val context = androidx.glance.LocalContext.current
Text(
text = context.getString(R.string.agenda_no_more_today),
maxLines = 1,
style = TextStyle(color = ink.secondary, fontSize = metrics.placeholder),
modifier = GlanceModifier
.fillMaxWidth()
.padding(start = if (backed) CLASSIC_CARD_H_PAD + STRIPE_INSET else TEXT_INDENT, end = 8.dp, top = 2.dp, bottom = metrics.rowVPad + 2.dp)
.clickable(openDayAction(context, date)),
)
}
/** A classic event row: the calendar's colour stripe beside the title and time; a dimmed one fades both. */
@Composable
private fun ClassicEventRow(row: AgendaRow.Event, data: AgendaWidgetData.Ready, backed: Boolean, ink: ClassicInk, metrics: AgendaMetrics) {
val context = androidx.glance.LocalContext.current
val event = row.event
val dimmed = row.dimmed(data)
fun accent(dark: Boolean) = eventAccent(event.color, dark, data.soften).let {
if (dimmed) it.copy(alpha = EventDimAlpha) else it
}
Row(
modifier = GlanceModifier
.fillMaxWidth()
.padding(horizontal = if (backed) CLASSIC_CARD_H_PAD else CLASSIC_ROW_H_PAD, vertical = metrics.rowVPad)
.clickable(actionStartActivity(event.openIntent(context, CalendarView.Agenda))),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = GlanceModifier
.width(STRIPE_WIDTH)
.height(metrics.contentH)
.cornerRadius(3.dp)
.background(themedColor(day = accent(dark = false), night = accent(dark = true))),
) {}
Spacer(GlanceModifier.width(STRIPE_GAP))
Column(modifier = GlanceModifier.defaultWeight()) {
Text(
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
maxLines = 1,
style = TextStyle(
color = if (dimmed) ink.title.withAlpha(CLASSIC_DIM_INK) else ink.title,
fontSize = metrics.eventTitle,
textDecoration = glanceDeclinedDecoration(event.isCrossedOut),
),
)
Text(
text = eventTimeSummary(context, event, row.date, data.is24Hour),
maxLines = 1,
style = TextStyle(
color = if (dimmed) ink.secondary.withAlpha(CLASSIC_DIM_INK) else ink.secondary,
fontSize = metrics.eventTime,
),
)
}
}
}
/** Space above a day's label on its own backing card, and its rows' inset, clear of the card's corners. */
private val CLASSIC_CARD_TOP_PAD = 8.dp
private val CLASSIC_CARD_H_PAD = 10.dp
/** How strongly a finished event's text shows in the classic style, on any fill. */
private const val CLASSIC_DIM_INK = 0.5f
/** The inverted secondary text's strength, as `onSurfaceVariant` sits below `onSurface`. */
private const val INVERTED_SECONDARY_INK = 0.75f
/** Where each row sits among the rows of its day, from each row's date, for the grouped-card shapes. */
private fun dayPositions(dates: List<LocalDate>): List<Position> = buildList {
var start = 0
for (end in 1..rows.size) {
if (end == rows.size || rows[end].date != rows[start].date) {
for (end in 1..dates.size) {
if (end == dates.size || dates[end] != dates[start]) {
for (i in start until end) add(positionOf(i - start, end - start))
start = end
}
@@ -343,7 +547,7 @@ private fun DatePill(date: LocalDate, today: LocalDate, cardColor: ColorProvider
.height(metrics.rowHeight)
.cornerRadius(metrics.pillWidth / 2)
.background(container)
.clickable(actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Agenda)))
.clickable(openDayAction(context, date))
.semantics { contentDescription = label },
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically,
@@ -375,11 +579,7 @@ private fun PlaceholderRow(date: LocalDate, metrics: AgendaMetrics) {
.fillMaxWidth()
.height(metrics.rowHeight)
.padding(horizontal = ROW_H_PAD)
.clickable(
actionStartActivity(
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
),
),
.clickable(openDayAction(context, date)),
contentAlignment = Alignment.CenterStart,
) {
Text(
@@ -391,14 +591,19 @@ private fun PlaceholderRow(date: LocalDate, metrics: AgendaMetrics) {
}
/** An event row's light and dark fill: its calendar's container tone, faded when the row is dimmed. */
private data class EventFill(val day: Color, val night: Color)
private data class EventFill(val day: Color, val night: Color, val dimmed: Boolean)
/** Whether this row is drawn faded as a finished event. */
private fun AgendaRow.Event.dimmed(data: AgendaWidgetData.Ready): Boolean =
data.pastDisplay == PastEventDisplay.DIM && event.hasEnded(data.now)
private fun AgendaRow.Event.fill(data: AgendaWidgetData.Ready): EventFill {
val dimmed = data.pastDisplay == PastEventDisplay.DIM && event.hasEnded(data.now)
fun tone(dark: Boolean) = eventFill(event.color, dark, data.soften).let {
if (dimmed) it.copy(alpha = WidgetDimFillAlpha) else it
}
return EventFill(day = tone(dark = false), night = tone(dark = true))
val dimmed = dimmed(data)
return EventFill(
day = widgetEventFill(event.color, dark = false, data.soften, dimmed),
night = widgetEventFill(event.color, dark = true, data.soften, dimmed),
dimmed = dimmed,
)
}
@Composable
@@ -411,27 +616,17 @@ private fun EventRow(
) {
val context = androidx.glance.LocalContext.current
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
val titleInk = themedColor(day = widgetEventInk(fill.day, 1f), night = widgetEventInk(fill.night, 1f))
val titleInk = themedColor(day = widgetEventInk(fill.day, 1f, fill.dimmed), night = widgetEventInk(fill.night, 1f, fill.dimmed))
val timeInk = themedColor(
day = widgetEventInk(fill.day, SECONDARY_INK_ALPHA),
night = widgetEventInk(fill.night, SECONDARY_INK_ALPHA),
day = widgetEventInk(fill.day, SECONDARY_INK_ALPHA, fill.dimmed),
night = widgetEventInk(fill.night, SECONDARY_INK_ALPHA, fill.dimmed),
)
Column(
modifier = GlanceModifier
.fillMaxWidth()
.height(metrics.rowHeight)
.padding(horizontal = ROW_H_PAD)
.clickable(
actionStartActivity(
MainActivity.openEventIntent(
context = context,
eventId = event.eventId,
beginMillis = event.start.toEpochMilliseconds(),
endMillis = event.end.toEpochMilliseconds(),
source = CalendarView.Agenda,
),
),
),
.clickable(actionStartActivity(event.openIntent(context, CalendarView.Agenda))),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
@@ -474,7 +669,7 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate):
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
// Weekday + date in the locale's own field order (compact: no year), rather
// than a hardcoded day-month layout.
val formatted = localizedDateFormatter(locale, "EEEdMMM").format(java)
val formatted = localizedDateFormatter(locale, if (date.year == today.year) "EEEdMMM" else "EEEdMMMy").format(java)
return if (relative != null) "$relative · $formatted" else formatted
}
@@ -69,6 +69,16 @@ import de.jeanlucmakiola.floret.glance.GlanceWidgetPreview
import de.jeanlucmakiola.floret.glance.WallpaperWindow
import de.jeanlucmakiola.floret.glance.WidgetBackground
import kotlin.math.roundToInt
import de.jeanlucmakiola.floret.components.GroupedSliderRow
import de.jeanlucmakiola.floret.components.InfoPopover
import de.jeanlucmakiola.calendula.widget.AgendaWidgetStyle
import java.text.NumberFormat
import de.jeanlucmakiola.floret.locale.currentLocale
import androidx.compose.runtime.remember
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.Switch
import androidx.compose.foundation.layout.Row
private val PreviewMaxHeight = 280.dp
@@ -236,6 +246,42 @@ private fun ConfigOptions(
onSelect = { onAppearance(state.appearance.copy(background = it)) },
position = Position.Top,
)
val faded = state.appearance.background != WidgetBackground.TRANSPARENT
val content = state.draft.content
val classic = state.kind == WidgetKind.AGENDA && content.style == AgendaWidgetStyle.CLASSIC
if (faded) {
val opacity = state.appearance.opacity
GroupedSliderRow(
title = stringResource(R.string.widget_config_opacity),
value = opacity,
onValueChange = { onAppearance(state.appearance.copy(opacity = it)) },
position = Position.Middle,
valueLabel = currentLocale().let { locale ->
remember(opacity, locale) { NumberFormat.getPercentInstance(locale).format(opacity) }
},
valueRange = MIN_OPACITY..1f,
steps = OPACITY_STEPS,
titleAccessory = {
InfoPopover(
text = stringResource(
if (classic) R.string.widget_config_readability_hint_classic else R.string.widget_config_readability_hint,
),
contentDescription = stringResource(R.string.widget_config_readability_info),
)
},
)
}
if (faded && classic) {
GroupedRow(
title = stringResource(R.string.widget_config_invert_text),
summary = stringResource(R.string.widget_config_invert_text_summary),
position = Position.Middle,
trailing = {
Switch(checked = content.invertText, onCheckedChange = { onContent(content.copy(invertText = it)) })
},
onClick = { onContent(content.copy(invertText = !content.invertText)) },
)
}
GroupedChoiceRow(
title = stringResource(R.string.settings_theme),
options = WidgetTheme.entries,
@@ -265,6 +311,7 @@ private fun ContentOptions(
onCalendarShown: (Long, Boolean) -> Unit,
) {
val options = state.draft.content
var showStyle by rememberSaveable { mutableStateOf(false) }
var showRange by rememberSaveable { mutableStateOf(false) }
var showSize by rememberSaveable { mutableStateOf(false) }
var showPast by rememberSaveable { mutableStateOf(false) }
@@ -277,6 +324,12 @@ private fun ContentOptions(
GroupedSectionHeader(stringResource(R.string.widget_config_content))
rows.forEach { option ->
when (option) {
WidgetOption.STYLE -> GroupedRow(
title = stringResource(R.string.widget_config_style),
summary = agendaStyleLabel(options.style),
position = position(option),
onClick = { showStyle = true },
)
WidgetOption.RANGE -> GroupedRow(
title = stringResource(R.string.widget_config_range),
summary = agendaRangeLabel(options.range),
@@ -319,6 +372,17 @@ private fun ContentOptions(
)
}
if (showStyle) {
OptionPicker(
title = stringResource(R.string.widget_config_style),
predictiveBack = true,
options = AgendaWidgetStyle.entries,
selected = options.style,
label = { agendaStyleLabel(it) },
onSelect = { onChange(options.copy(style = it)) },
onDismiss = { showStyle = false },
)
}
if (showRange) {
AgendaRangePicker(
title = stringResource(R.string.widget_config_range),
@@ -356,6 +420,18 @@ private fun ContentOptions(
}
}
@Composable
private fun agendaStyleLabel(style: AgendaWidgetStyle): String = stringResource(
when (style) {
AgendaWidgetStyle.CARDS -> R.string.widget_config_style_cards
AgendaWidgetStyle.CLASSIC -> R.string.widget_config_style_classic
},
)
/** The faintest fill the opacity slider allows, and its 10% steps between that and opaque. */
private const val MIN_OPACITY = 0.2f
private const val OPACITY_STEPS = 7
@Composable
internal fun backgroundLabel(background: WidgetBackground): String = stringResource(
when (background) {
@@ -42,6 +42,7 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.coversWholeDays
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.widget.openIntent
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
@@ -132,13 +133,7 @@ private fun EventCountdown(event: EventInstance, data: CountdownData.Ready) {
val context = LocalContext.current
val label = countdownLabel(event.counted(systemZone()), data.now, systemZone())
val open = actionStartActivity(
MainActivity.openEventIntent(
context = context,
eventId = event.eventId,
beginMillis = event.start.toEpochMilliseconds(),
endMillis = event.end.toEpochMilliseconds(),
source = CalendarView.Agenda,
),
event.openIntent(context, CalendarView.Agenda),
)
// Over a wallpaper the text sits on its own card, as every other widget's rows do.
val card = GlanceModifier.wallpaperBacking(radius = 16.dp)
@@ -36,11 +36,25 @@ internal fun <T : Any> mergeGaps(columns: Int, cellAt: (Int) -> GridCell<T>?): L
return cells
}
/** What a cell of the single mixed lane shows: an event's bar, or a day's event count. */
internal sealed interface LaneItem {
data class Bar(val event: EventInstance) : LaneItem
data class Count(val count: Int) : LaneItem
}
/**
* One week of the grid seated with [laneCap] lanes. [showsOverflow] is off when
* the week has no room for even the "+N" row.
* One week of the grid seated with [laneCap] lanes, drawn [compact] when the
* row is too short for a full one. [showsOverflow] is off when the week has no
* room for the "+N" row; a [mixed] single lane then shows each day's count in
* place of its bar where the day has more than fits (#491).
*/
internal class WeekLayout(val week: MonthWeek, val laneCap: Int, val showsOverflow: Boolean = true) {
internal class WeekLayout(
val week: MonthWeek,
val laneCap: Int,
val showsOverflow: Boolean = true,
val compact: Boolean = false,
val mixed: Boolean = false,
) {
private val seating: List<DaySeating> = week.days.indices.map { week.seating(it, laneCap) }
/** Lanes filled from the top; empty lanes below are not drawn. */
@@ -55,6 +69,16 @@ internal class WeekLayout(val week: MonthWeek, val laneCap: Int, val showsOverfl
}
}
/** The single lane of a [mixed] week, bars cut short where a day shows its count instead; null otherwise. */
val mixedLane: List<GridCell<LaneItem>>? = if (!mixed) null else mergeGaps(week.days.size) { col ->
val day = seating[col]
if (day.hidden.isNotEmpty()) return@mergeGaps GridCell(LaneItem.Count(week.countByDay[week.days[col]] ?: day.hidden.size), 1)
val seat = day.seated.firstOrNull() ?: return@mergeGaps null
val spanEnd = if (seat.bar) week.spanAt(col, seat.lane)?.endCol ?: col else col
val end = (col + 1..spanEnd).firstOrNull { seating[it].hidden.isNotEmpty() }?.minus(1) ?: spanEnd
GridCell(LaneItem.Bar(seat.event), end - col + 1)
}
/** The "+N" row as counts and merged gaps; empty when everything fits. */
val overflow: List<GridCell<Int>> = seating.map { it.hidden.size }.let { hidden ->
if (hidden.none { it > 0 }) emptyList()
@@ -62,19 +86,31 @@ internal class WeekLayout(val week: MonthWeek, val laneCap: Int, val showsOverfl
}
}
/**
* Event lanes a week row [weekHeight] tall holds under its day number, held to
* [MAX_LANES] so a tall widget's payload stays bounded (#214).
*/
internal fun lanesFitting(weekHeight: Dp): Int =
((weekHeight.value - DAY_ROW_DP - LANES_TOP_DP) / LANE_ROW_DP).toInt().coerceIn(0, MAX_LANES)
/** How many lanes a week row holds, and whether they're the [compact] height. */
internal data class LaneFit(val lanes: Int, val compact: Boolean = false)
/**
* Seats each week in [lanes] lanes. A week whose events need more gives its
* last lane to the "+N" row instead.
* Event lanes a week row [weekHeight] tall holds under its day number, held to
* [MAX_LANES] so a tall widget's payload stays bounded (#214). A row too short
* for one full lane still gets a compact one when its text fits at [fontScale] (#491).
*/
internal fun fitWeeks(weeks: List<MonthWeek>, lanes: Int): List<WeekLayout> = weeks.map { week ->
if (lanes == 0) return@map WeekLayout(week, laneCap = 0, showsOverflow = false)
internal fun lanesFitting(weekHeight: Dp, fontScale: Float): LaneFit {
val room = weekHeight.value - DAY_ROW_DP - LANES_TOP_DP
val full = (room / LANE_ROW_DP).toInt().coerceIn(0, MAX_LANES)
val compactFits = room >= COMPACT_LANE_ROW_DP && fontScale <= COMPACT_MAX_FONT_SCALE
return if (full == 0 && compactFits) LaneFit(1, compact = true) else LaneFit(full)
}
/**
* Seats each week in [fit]'s lanes. A week whose events need more gives its
* last lane to the "+N" row instead. A single lane has no room for that row,
* so it mixes bars and per-day counts (#491).
*/
internal fun fitWeeks(weeks: List<MonthWeek>, fit: LaneFit): List<WeekLayout> = weeks.map { week ->
val lanes = fit.lanes
if (lanes <= 1) {
return@map WeekLayout(week, laneCap = lanes, showsOverflow = false, compact = fit.compact, mixed = lanes == 1)
}
val needed = WeekLayout(week, UNCAPPED_LANES).usedLanes
WeekLayout(week, if (needed <= lanes) lanes else lanes - 1)
}
@@ -85,4 +121,7 @@ internal const val MAX_LANES = 8
private const val DAY_ROW_DP = 18f
private const val LANES_TOP_DP = 2f
private const val LANE_ROW_DP = 15f
/** Matches `widget_month_lane_compact`'s height; its 9sp text clips past [COMPACT_MAX_FONT_SCALE]. */
private const val COMPACT_LANE_ROW_DP = 12f
private const val COMPACT_MAX_FONT_SCALE = 1f
private const val UNCAPPED_LANES = 64
@@ -91,6 +91,21 @@ private fun weekViews(
week.setViewVisibility(TODAY_IDS[col], if (isToday) View.VISIBLE else View.GONE)
if (isToday) week.setImageTint(context, TODAY_IDS[col], colors.primary)
}
layout.mixedLane?.let { cells ->
val lane = laneViews(context, layout.compact)
cells.forEach { cell ->
lane.addView(
R.id.lane,
when (val item = cell.item) {
null -> gap(context, cell.cols)
is LaneItem.Bar -> bar(context, item.event, cell.cols, soften, mode)
is LaneItem.Count -> more(context, item.count, colors)
},
)
}
week.addView(R.id.week_lanes, lane)
return week
}
layout.lanes.forEach { cells ->
val lane = laneViews(context)
cells.forEach { cell ->
@@ -106,10 +121,7 @@ private fun weekViews(
if (count == null) {
lane.addView(R.id.lane, gap(context, cell.cols))
} else {
val more = RemoteViews(context.packageName, R.layout.widget_month_more)
more.setTextViewText(R.id.more, "+$count")
more.setTextColor(context, R.id.more, colors.onSurfaceVariant)
lane.addView(R.id.lane, more)
lane.addView(R.id.lane, more(context, count, colors))
}
}
week.addView(R.id.week_lanes, lane)
@@ -117,8 +129,15 @@ private fun weekViews(
return week
}
private fun laneViews(context: Context) =
RemoteViews(context.packageName, R.layout.widget_month_lane).apply { removeAllViews(R.id.lane) }
private fun more(context: Context, count: Int, colors: GridColors) =
RemoteViews(context.packageName, R.layout.widget_month_more).apply {
setTextViewText(R.id.more, "+$count")
setTextColor(context, R.id.more, colors.onSurfaceVariant)
}
private fun laneViews(context: Context, compact: Boolean = false) =
RemoteViews(context.packageName, if (compact) R.layout.widget_month_lane_compact else R.layout.widget_month_lane)
.apply { removeAllViews(R.id.lane) }
private fun gap(context: Context, cols: Int) = RemoteViews(context.packageName, GAP_LAYOUTS[cols - 1])
@@ -254,7 +254,7 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) {
val context = LocalContext.current
val fontScale = context.resources.configuration.fontScale
val weekHeight = (LocalSize.current.height - gridChromeHeight(fontScale)) / weeks.size
val layouts = fitWeeks(weeks, lanesFitting(weekHeight))
val layouts = fitWeeks(weeks, lanesFitting(weekHeight, fontScale))
val mode = LocalWidgetThemeMode.current
val colors = GridColors(
onSurface = remoteColor(WidgetColorRole.ON_SURFACE),
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/lane"
android:layout_width="match_parent"
android:layout_height="12dp"
android:orientation="horizontal"
android:paddingBottom="1dp"
android:weightSum="7" />
+9
View File
@@ -517,6 +517,15 @@
<string name="widget_config_background_tinted">Tinted</string>
<string name="widget_config_background_transparent">Transparent</string>
<string name="widget_config_theme_follow_app">Follow app</string>
<string name="widget_config_opacity">Opacity</string>
<string name="widget_config_invert_text">Invert text colour</string>
<string name="widget_config_invert_text_summary">Light text on a dark theme turns dark, and the other way round</string>
<string name="widget_config_readability_info">About readability</string>
<string name="widget_config_readability_hint">At low opacity the wallpaper shows through, so some text may be harder to read. Raise the opacity if it is.</string>
<string name="widget_config_readability_hint_classic">At low opacity the wallpaper shows through, so some text may be hard to read. If it is, raise the opacity or invert the text colour.</string>
<string name="widget_config_style">Style</string>
<string name="widget_config_style_cards">Cards</string>
<string name="widget_config_style_classic">Classic</string>
<string name="widget_config_content">Content</string>
<string name="widget_config_range">Time range</string>
<string name="widget_config_text_size">Text size</string>
@@ -0,0 +1,33 @@
package de.jeanlucmakiola.calendula.widget
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.mutablePreferencesOf
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.floret.glance.WidgetBackground
import org.junit.jupiter.api.Test
/** Per-widget options survive a save and read back (#489). */
class WidgetConfigTest {
private val defaults = AgendaWidgetOptions(AgendaRange.Week, WidgetSize.SMALL, true, PastEventDisplay.SHOW)
@Test
fun `appearance and agenda style round-trip`() {
val appearance = WidgetAppearance(WidgetBackground.SOLID, WidgetTheme.DARK, opacity = 0.6f)
val options = defaults.copy(style = AgendaWidgetStyle.CLASSIC)
val prefs = mutablePreferencesOf().apply {
putAppearance(appearance)
putAgendaOptions(options)
}
assertThat(prefs.widgetAppearance).isEqualTo(appearance)
assertThat(prefs.agendaWidgetOptions(defaults)).isEqualTo(options)
}
@Test
fun `a widget saved before 2_24_1 stays opaque cards`() {
assertThat(emptyPreferences().widgetAppearance.opacity).isEqualTo(1f)
assertThat(emptyPreferences().agendaWidgetOptions(defaults).style).isEqualTo(AgendaWidgetStyle.CARDS)
}
}
@@ -0,0 +1,28 @@
package de.jeanlucmakiola.calendula.widget.agenda
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.LocalDate
import org.junit.jupiter.api.Test
/** The classic agenda's day labels and row cap (#489). */
class ClassicItemsTest {
private val mon = LocalDate(2026, 11, 9)
private val tue = LocalDate(2026, 11, 10)
@Test
fun `each day gets one label before its rows`() {
val rows = listOf(AgendaRow.Placeholder(mon), AgendaRow.Placeholder(tue), AgendaRow.Placeholder(tue))
assertThat(classicItems(rows).map { it::class }).containsExactly(
ClassicItem.Header::class, ClassicItem.Entry::class,
ClassicItem.Header::class, ClassicItem.Entry::class, ClassicItem.Entry::class,
).inOrder()
}
@Test
fun `labels count towards the cap and a stranded one is dropped`() {
val rows = listOf(AgendaRow.Placeholder(mon), AgendaRow.Placeholder(tue))
val items = classicItems(rows, limit = 3)
assertThat(items).containsExactly(ClassicItem.Header(mon), ClassicItem.Entry(rows[0])).inOrder()
}
}
@@ -42,33 +42,80 @@ class MonthGridTest {
@Test
fun `a week that fits keeps every lane`() {
val layouts = fitWeeks(busyWeeks(perDay = 2), lanes = 3)
val layouts = fitWeeks(busyWeeks(perDay = 2), LaneFit(3))
assertThat(layouts.map { it.laneCap }.distinct()).containsExactly(3)
assertThat(layouts.all { it.overflow.isEmpty() }).isTrue()
}
@Test
fun `a week that doesn't fit gives its last lane to the overflow`() {
val layouts = fitWeeks(busyWeeks(perDay = 5), lanes = 3)
val layouts = fitWeeks(busyWeeks(perDay = 5), LaneFit(3))
assertThat(layouts.map { it.laneCap }.distinct()).containsExactly(2)
assertThat(layouts.all { it.overflow.isNotEmpty() }).isTrue()
}
@Test
fun `a taller week fits more lanes`() {
assertThat(lanesFitting(19.dp)).isEqualTo(0)
assertThat(lanesFitting(50.dp)).isEqualTo(2)
assertThat(lanesFitting(100.dp)).isEqualTo(5)
assertThat(lanesFitting(19.dp, fontScale = 1f)).isEqualTo(LaneFit(0))
assertThat(lanesFitting(50.dp, fontScale = 1f)).isEqualTo(LaneFit(2))
assertThat(lanesFitting(100.dp, fontScale = 1f)).isEqualTo(LaneFit(5))
}
@Test
fun `a row too short for a full lane gets a compact one`() {
assertThat(lanesFitting(34.dp, fontScale = 1f)).isEqualTo(LaneFit(1, compact = true))
assertThat(lanesFitting(32.dp, fontScale = 1f)).isEqualTo(LaneFit(1, compact = true))
assertThat(lanesFitting(31.dp, fontScale = 1f)).isEqualTo(LaneFit(0))
assertThat(lanesFitting(35.dp, fontScale = 1f)).isEqualTo(LaneFit(1))
}
@Test
fun `no compact lane at a large font scale`() {
assertThat(lanesFitting(34.dp, fontScale = 1.1f)).isEqualTo(LaneFit(0))
}
@Test
fun `a single lane shows counts on busy days`() {
val layouts = fitWeeks(busyWeeks(perDay = 5), LaneFit(1))
assertThat(layouts.all { it.mixedLane == List(7) { GridCell<LaneItem>(LaneItem.Count(5), 1) } }).isTrue()
}
@Test
fun `a single lane keeps bars on days that fit`() {
val lone = timedEvent(monday.plus(1, DateTimeUnit.DAY), 9, zone = zone, id = 1, title = "Lone")
val busy = (2L..4L).map { timedEvent(monday.plus(3, DateTimeUnit.DAY), it.toInt() + 8, zone = zone, id = it, title = "B$it") }
val week = fitWeeks(weeks(listOf(lone) + busy), LaneFit(1, compact = true))[1]
assertThat(week.compact).isTrue()
assertThat(week.mixedLane).containsExactly(
GridCell<LaneItem>(null, 1),
GridCell<LaneItem>(LaneItem.Bar(lone), 1),
GridCell<LaneItem>(null, 1),
GridCell<LaneItem>(LaneItem.Count(3), 1),
GridCell<LaneItem>(null, 3),
).inOrder()
}
@Test
fun `a single lane cuts a multi-day bar around a busy day`() {
val trip = allDayEvent(monday, monday.plus(3, DateTimeUnit.DAY), id = 1, title = "Trip")
val busy = timedEvent(monday.plus(1, DateTimeUnit.DAY), 9, zone = zone, id = 2, title = "Busy")
val week = fitWeeks(weeks(listOf(trip, busy)), LaneFit(1))[1]
assertThat(week.mixedLane).containsExactly(
GridCell<LaneItem>(LaneItem.Bar(trip), 1),
GridCell<LaneItem>(LaneItem.Count(2), 1),
GridCell<LaneItem>(LaneItem.Bar(trip), 2),
GridCell<LaneItem>(null, 3),
).inOrder()
}
@Test
fun `lanes stop at the cap however tall the week`() {
assertThat(lanesFitting(1000.dp)).isEqualTo(MAX_LANES)
assertThat(lanesFitting(1000.dp, fontScale = 1f)).isEqualTo(LaneFit(MAX_LANES))
}
@Test
fun `a week with no room for a lane draws no overflow row either`() {
val layouts = fitWeeks(busyWeeks(perDay = 5), lanes = 0)
val layouts = fitWeeks(busyWeeks(perDay = 5), LaneFit(0))
assertThat(layouts.none { it.showsOverflow }).isTrue()
assertThat(layouts.all { it.lanes.isEmpty() }).isTrue()
}
@@ -0,0 +1,12 @@
الجديد
• نمط كلاسيكي لأداة جدول الأعمال.
• شريط تمرير لشفافية خلفية الأدوات.
الإصلاحات
• أرقام الأسابيع كانت أقل بواحد عندما يبدأ الأسبوع يوم الأحد.
• أداة الشهر تعرض الأحداث بأي ارتفاع.
• الأحداث الباهتة في الأدوات الداكنة مقروءة من جديد.
• الأدوات المضبوطة على فاتح أو داكن تطابق ألوان النظام.
التغييرات
• أذونات أقل.
@@ -0,0 +1,12 @@
Новае
• Класічны стыль для віджэта спісу падзей.
• Паўзунок непразрыстасці для фону віджэтаў.
Выпраўлена
• Нумары тыдняў былі на адзін меншыя, калі тыдзень пачынаецца ў нядзелю.
• Віджэт месяца паказвае падзеі пры любой вышыні.
• Прыглушаныя падзеі ў цёмных віджэтах зноў чытэльныя.
• Віджэты са светлай ці цёмнай тэмай адпавядаюць колерам сістэмы.
Зменена
• Менш дазволаў.
@@ -0,0 +1,12 @@
Novinky
• Klasický styl widgetu agendy.
• Posuvník průhlednosti pozadí widgetů.
Opraveno
• Čísla týdnů byla o jedno nižší, když týden začíná nedělí.
• Widget měsíce zobrazí události v jakékoli výšce.
• Ztlumené události v tmavých widgetech jsou opět čitelné.
• Widgety se světlým nebo tmavým motivem odpovídají barvám systému.
Změněno
• Méně oprávnění.
@@ -0,0 +1,12 @@
Neu
• Klassischer Stil für das Terminliste-Widget.
• Deckkraft-Regler für Widget-Hintergründe.
Behoben
• Kalenderwochen waren bei Wochenstart Sonntag eine zu niedrig.
• Monats-Widget zeigt Termine in jeder Höhe.
• Abgedunkelte Termine in dunklen Widgets wieder lesbar.
• Widgets mit Hell oder Dunkel passen zu den Systemfarben.
Geändert
• Weniger Berechtigungen.
@@ -0,0 +1,12 @@
New
• Classic style for the agenda widget.
• Opacity slider for widget backgrounds.
Fixed
• Week numbers were one too low when weeks start on Sunday.
• The month widget shows events at every height.
• Dimmed events in dark widgets are readable again.
• Widgets set to Light or Dark match the system colours.
Changed
• Fewer permissions.
@@ -0,0 +1,12 @@
Novedades
• Estilo clásico para el widget de agenda.
• Control de opacidad para el fondo de los widgets.
Correcciones
• Los números de semana salían uno menos con semanas que empiezan en domingo.
• El widget de mes muestra eventos con cualquier altura.
• Los eventos atenuados en widgets oscuros vuelven a leerse bien.
• Los widgets en claro u oscuro coinciden con los colores del sistema.
Cambios
• Menos permisos.
@@ -0,0 +1,12 @@
Nouveautés
• Style classique pour le widget agenda.
• Curseur d’opacité pour le fond des widgets.
Corrections
• Les numéros de semaine avaient un de moins avec des semaines commençant le dimanche.
• Le widget mois affiche les événements à toutes les hauteurs.
• Les événements estompés sont de nouveau lisibles dans les widgets sombres.
• Les widgets en clair ou sombre suivent les couleurs du système.
Modifications
• Moins d’autorisations.
@@ -0,0 +1,12 @@
Újdonságok
• Klasszikus stílus a napirend widgethez.
• Átlátszóság-csúszka a widgetek hátteréhez.
Javítva
• A hetek száma eggyel kevesebb volt, ha a hét vasárnap kezdődik.
• A havi widget bármilyen magasságban mutatja az eseményeket.
• A halványított események ismét olvashatók a sötét widgeteken.
• A világos vagy sötét widgetek a rendszer színeit követik.
Változások
• Kevesebb engedély.
@@ -0,0 +1,12 @@
Novità
• Stile classico per il widget agenda.
• Cursore di opacità per lo sfondo dei widget.
Correzioni
• I numeri delle settimane erano uno in meno con settimane che iniziano di domenica.
• Il widget mese mostra gli eventi a ogni altezza.
• Gli eventi attenuati nei widget scuri sono di nuovo leggibili.
• I widget in chiaro o scuro seguono i colori di sistema.
Modifiche
• Meno autorizzazioni.
@@ -0,0 +1,12 @@
新機能
• 予定リストウィジェットにクラシックスタイルを追加。
• ウィジェット背景の不透明度スライダーを追加。
修正
• 週の始まりが日曜日のとき、週番号が1つ少なく表示されていた問題。
• 月のウィジェットがどの高さでも予定を表示するように。
• 暗いウィジェットで薄く表示された予定が再び読みやすく。
• ライト/ダーク固定のウィジェットがシステムの色に合うように。
変更
• 必要な権限を削減。
@@ -0,0 +1,12 @@
Nieuw
• Klassieke stijl voor de agendawidget.
• Schuifregelaar voor de dekking van widgetachtergronden.
Opgelost
• Weeknummers waren één te laag als de week op zondag begint.
• De maandwidget toont afspraken op elke hoogte.
• Gedimde afspraken in donkere widgets zijn weer leesbaar.
• Widgets op Licht of Donker passen bij de systeemkleuren.
Gewijzigd
• Minder machtigingen.
@@ -0,0 +1,12 @@
Nowości
• Klasyczny styl widżetu agendy.
• Suwak przezroczystości tła widżetów.
Poprawki
• Numery tygodni były o jeden za niskie, gdy tydzień zaczyna się w niedzielę.
• Widżet miesiąca pokazuje wydarzenia przy każdej wysokości.
• Przyciemnione wydarzenia w ciemnych widżetach znów są czytelne.
• Widżety z jasnym lub ciemnym motywem pasują do kolorów systemu.
Zmiany
• Mniej uprawnień.
@@ -0,0 +1,12 @@
Novidades
• Estilo clássico para o widget de agenda.
• Controle de opacidade para o fundo dos widgets.
Correções
• Os números das semanas ficavam um a menos com semanas começando no domingo.
• O widget de mês mostra eventos em qualquer altura.
• Eventos esmaecidos em widgets escuros voltam a ser legíveis.
• Widgets em claro ou escuro seguem as cores do sistema.
Alterações
• Menos permissões.
@@ -0,0 +1,12 @@
Novidades
• Estilo clássico para o widget de agenda.
• Controlo de opacidade para o fundo dos widgets.
Correções
• Os números das semanas ficavam um abaixo com semanas a começar ao domingo.
• O widget de mês mostra eventos em qualquer altura.
• Eventos esbatidos em widgets escuros voltam a ser legíveis.
• Widgets em claro ou escuro seguem as cores do sistema.
Alterações
• Menos permissões.
@@ -0,0 +1,12 @@
Новое
• Классический стиль для виджета повестки.
• Ползунок непрозрачности для фона виджетов.
Исправлено
• Номера недель были на единицу меньше, если неделя начинается с воскресенья.
• Виджет месяца показывает события при любой высоте.
• Приглушённые события в тёмных виджетах снова читаемы.
• Виджеты со светлой или тёмной темой соответствуют цветам системы.
Изменено
• Меньше разрешений.
@@ -0,0 +1,12 @@
Novinky
• Klasický štýl widgetu agendy.
• Posuvník priehľadnosti pozadia widgetov.
Opravené
• Čísla týždňov boli o jedno nižšie, keď týždeň začína nedeľou.
• Widget mesiaca zobrazí udalosti v akejkoľvek výške.
• Stlmené udalosti v tmavých widgetoch sú opäť čitateľné.
• Widgety so svetlým alebo tmavým motívom zodpovedajú farbám systému.
Zmenené
• Menej povolení.
@@ -0,0 +1,12 @@
新功能
• 日程列表小部件新增经典样式。
• 小部件背景新增不透明度滑块。
修复
• 每周从星期日开始时,周数少了一。
• 月份小部件在任何高度都会显示日程。
• 深色小部件中变暗的日程重新清晰可读。
• 设为浅色或深色的小部件与系统颜色一致。
变更
• 减少了所需权限。
@@ -0,0 +1,12 @@
新功能
• 行程列表小工具新增經典樣式。
• 小工具背景新增不透明度滑桿。
修正
• 每週從星期日開始時,週數少了一。
• 月份小工具在任何高度都會顯示行程。
• 深色小工具中變暗的行程重新清晰可讀。
• 設為淺色或深色的小工具與系統顏色一致。
變更
• 減少了所需權限。