Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
610409012f | ||
|
|
7e45656479 | ||
|
|
94b04c1365 | ||
|
|
d1557825c6 | ||
|
|
d43a741c93 | ||
|
|
6da0648b77 | ||
|
|
3b48987ab7 | ||
|
|
04a677dc10 |
+31
-16
@@ -4,6 +4,7 @@ import android.Manifest
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.net.Uri
|
||||
import android.os.Parcel
|
||||
import android.provider.CalendarContract
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
@@ -24,17 +25,18 @@ import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
|
||||
/**
|
||||
* Renders the month widget for a packed month through Glance's own translator
|
||||
* and counts what it inflates (#372): the view-id model in [WeekLayout] has to
|
||||
* match what Glance really builds, or a busy month fails with "too many views".
|
||||
* (#372) and checks what one size costs on the wire: the host's binder buffer is
|
||||
* 1 MB for every widget update at once, and Exact sends one copy per size (#214).
|
||||
*/
|
||||
@OptIn(ExperimentalGlanceApi::class)
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MonthWidgetViewBudgetTest {
|
||||
class MonthWidgetPayloadTest {
|
||||
|
||||
@get:Rule
|
||||
val permissionRule: GrantPermissionRule =
|
||||
@@ -95,31 +97,44 @@ class MonthWidgetViewBudgetTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPackedMonthRendersWithinGlancesViewIds() = runBlocking {
|
||||
for (size in listOf(DpSize(250.dp, 250.dp), DpSize(320.dp, 360.dp), DpSize(420.dp, 520.dp))) {
|
||||
fun aPackedMonthRendersWithinItsPayload() = runBlocking {
|
||||
for (size in listOf(DpSize(250.dp, 250.dp), DpSize(320.dp, 360.dp), DpSize(420.dp, 520.dp), DpSize(600.dp, 1000.dp))) {
|
||||
val views = MonthWidget().compose(context, size = size)
|
||||
var total = 0
|
||||
var texts = 0
|
||||
val bytes = Parcel.obtain().let { parcel ->
|
||||
try {
|
||||
views.writeToParcel(parcel, 0)
|
||||
parcel.dataSize()
|
||||
} finally {
|
||||
parcel.recycle()
|
||||
}
|
||||
}
|
||||
var dayNumbers = 0
|
||||
var bars = 0
|
||||
instrumentation.runOnMainSync {
|
||||
val root = views.apply(context, FrameLayout(context))
|
||||
fun walk(view: View) {
|
||||
total += 1
|
||||
if (view is TextView) texts += 1
|
||||
if (view.id in DAY_IDS && view is TextView && view.text.isNotEmpty()) dayNumbers += 1
|
||||
if (view.id == R.id.bar_title) bars += 1
|
||||
if (view is ViewGroup) for (i in 0 until view.childCount) walk(view.getChildAt(i))
|
||||
}
|
||||
walk(root)
|
||||
}
|
||||
Log.i(TAG, "size=$size views=$total texts=$texts budget=$VIEW_BUDGET")
|
||||
// The grid itself rendered, not Glance's error layout: every day number is there.
|
||||
assertThat(texts).isAtLeast(35)
|
||||
assertThat(total).isAtMost(GLANCE_VIEW_IDS)
|
||||
Log.i(TAG, "size=$size bytes=$bytes days=$dayNumbers bars=$bars")
|
||||
// The grid itself rendered, not Glance's error layout: a number in every day of every week.
|
||||
assertThat(dayNumbers % 7).isEqualTo(0)
|
||||
assertThat(dayNumbers).isAtLeast(28)
|
||||
assertThat(bytes).isAtMost(MAX_BYTES_PER_SIZE)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "MonthWidgetBudget"
|
||||
const val ACCOUNT = "calendula-widget-budget-test"
|
||||
const val TAG = "MonthWidgetPayload"
|
||||
const val ACCOUNT = "calendula-widget-payload-test"
|
||||
const val EVENTS_PER_DAY = 6
|
||||
const val GLANCE_VIEW_IDS = 499
|
||||
|
||||
val DAY_IDS = setOf(R.id.day_0, R.id.day_1, R.id.day_2, R.id.day_3, R.id.day_4, R.id.day_5, R.id.day_6)
|
||||
|
||||
/** A quarter of the host's buffer, leaving room for a second size and every other widget. */
|
||||
const val MAX_BYTES_PER_SIZE = 256 * 1024
|
||||
}
|
||||
}
|
||||
@@ -318,6 +318,16 @@
|
||||
android:exported="false" />
|
||||
|
||||
<!-- Home-screen widgets (Glance). Exported: the launcher/host binds them. -->
|
||||
<activity
|
||||
android:name=".widget.config.WidgetConfigActivity"
|
||||
android:exported="true"
|
||||
android:excludeFromRecents="true"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/Theme.Calendula.WidgetConfig">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<receiver
|
||||
android:name=".widget.agenda.AgendaWidgetReceiver"
|
||||
android:label="@string/widget_agenda_label"
|
||||
|
||||
@@ -154,8 +154,6 @@ class SettingsPrefs @Inject constructor(
|
||||
val dragToReschedule: Flow<Boolean> = prefs.flow(SettingsKeys.dragToReschedule)
|
||||
val tapOpensEditor: Flow<Boolean> = prefs.flow(SettingsKeys.tapOpensEditor)
|
||||
val agendaScreenRange: Flow<AgendaRange> = prefs.flow(SettingsKeys.agendaScreenRange)
|
||||
val agendaWidgetRange: Flow<AgendaRange> = prefs.flow(SettingsKeys.agendaWidgetRange)
|
||||
val widgetSize: Flow<WidgetSize> = prefs.flow(SettingsKeys.widgetSize)
|
||||
val agendaShowRangeBar: Flow<Boolean> = prefs.flow(SettingsKeys.agendaShowRangeBar)
|
||||
val agendaShowToday: Flow<Boolean> = prefs.flow(SettingsKeys.agendaShowToday)
|
||||
val defaultView: Flow<CalendarView> = prefs.flow(SettingsKeys.defaultView)
|
||||
@@ -231,8 +229,6 @@ class SettingsPrefs @Inject constructor(
|
||||
suspend fun setDragToReschedule(enabled: Boolean) = prefs.set(SettingsKeys.dragToReschedule, enabled)
|
||||
suspend fun setTapOpensEditor(enabled: Boolean) = prefs.set(SettingsKeys.tapOpensEditor, enabled)
|
||||
suspend fun setAgendaScreenRange(range: AgendaRange) = prefs.set(SettingsKeys.agendaScreenRange, range)
|
||||
suspend fun setAgendaWidgetRange(range: AgendaRange) = prefs.set(SettingsKeys.agendaWidgetRange, range)
|
||||
suspend fun setWidgetSize(size: WidgetSize) = prefs.set(SettingsKeys.widgetSize, size)
|
||||
suspend fun setAgendaShowRangeBar(enabled: Boolean) = prefs.set(SettingsKeys.agendaShowRangeBar, enabled)
|
||||
suspend fun setAgendaShowToday(enabled: Boolean) = prefs.set(SettingsKeys.agendaShowToday, enabled)
|
||||
suspend fun setDefaultView(view: CalendarView) = prefs.set(SettingsKeys.defaultView, view)
|
||||
@@ -497,13 +493,16 @@ object SettingsKeys {
|
||||
/** How far ahead the in-app Agenda screen shows events (v2.11). */
|
||||
val agendaScreenRange: Pref<AgendaRange> = agendaRangePref("agenda_screen_range")
|
||||
|
||||
/** How far ahead the agenda **widget** shows events (v2.11). */
|
||||
/**
|
||||
* How far ahead the agenda **widget** shows events (v2.11). Read-only since
|
||||
* widgets keep their own options (#292): the default for a widget that has
|
||||
* none yet.
|
||||
*/
|
||||
val agendaWidgetRange: Pref<AgendaRange> = agendaRangePref("agenda_widget_range")
|
||||
|
||||
/**
|
||||
* The size step the agenda widget draws its text at (#51). [WidgetSize.SMALL]
|
||||
* reproduces its original metrics. The month widget takes no size setting —
|
||||
* it divides the width it is given by seven (#103).
|
||||
* reproduces its original metrics. Read-only like [agendaWidgetRange] (#292).
|
||||
*/
|
||||
val widgetSize: Pref<WidgetSize> = enumPref("widget_size", WidgetSize.SMALL)
|
||||
|
||||
@@ -511,8 +510,8 @@ object SettingsKeys {
|
||||
val agendaShowRangeBar: Pref<Boolean> = booleanPref("agenda_show_range_bar", true)
|
||||
|
||||
/**
|
||||
* Whether the agenda (screen and widget) always anchors today at the top with
|
||||
* a "nothing left today" placeholder (issue #35).
|
||||
* Whether the agenda screen always anchors today at the top with a "nothing
|
||||
* left today" placeholder (issue #35), and the default for a widget (#292).
|
||||
*/
|
||||
val agendaShowToday: Pref<Boolean> = booleanPref("agenda_show_today", true)
|
||||
|
||||
|
||||
@@ -155,7 +155,7 @@ fun SettingsScreen(
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
WidgetsScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.Widgets)
|
||||
WidgetsScreen(onBack = { section = null }, visible = section == SettingsSection.Widgets)
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = section == SettingsSection.Licences,
|
||||
|
||||
@@ -16,7 +16,6 @@ import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineHours
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||
|
||||
/**
|
||||
* Settings screen state (M4). Persisted preferences are instant to read, so
|
||||
@@ -51,14 +50,10 @@ data class SettingsUiState(
|
||||
val tapOpensEditor: Boolean = false,
|
||||
/** How far ahead the in-app Agenda screen shows events (v2.11). */
|
||||
val agendaScreenRange: AgendaRange = AgendaRange.Month,
|
||||
/** How far ahead the agenda widget shows events (v2.11). */
|
||||
val agendaWidgetRange: AgendaRange = AgendaRange.Month,
|
||||
/** Whether the agenda (screen + widget) always anchors today at the top (#35). */
|
||||
/** Whether the agenda screen always anchors today at the top (#35); widgets have their own (#292). */
|
||||
val agendaShowToday: Boolean = true,
|
||||
/** Whether the agenda shows its top range bar — header + switcher (v2.11). */
|
||||
val agendaShowRangeBar: Boolean = true,
|
||||
/** The size step the agenda widget draws its text at (#51). */
|
||||
val widgetSize: WidgetSize = WidgetSize.SMALL,
|
||||
/** The calendar view the app opens on, and the home of the view back stack (M1). */
|
||||
val defaultView: CalendarView = CalendarView.Week,
|
||||
/** Which views the top-bar quick-switch button cycles through, and their order (#24). */
|
||||
|
||||
@@ -45,7 +45,6 @@ import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
|
||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
@@ -106,10 +105,8 @@ class SettingsViewModel @Inject constructor(
|
||||
dragToReschedule = this[SettingsKeys.dragToReschedule],
|
||||
tapOpensEditor = this[SettingsKeys.tapOpensEditor],
|
||||
agendaScreenRange = this[SettingsKeys.agendaScreenRange],
|
||||
agendaWidgetRange = this[SettingsKeys.agendaWidgetRange],
|
||||
agendaShowToday = this[SettingsKeys.agendaShowToday],
|
||||
agendaShowRangeBar = this[SettingsKeys.agendaShowRangeBar],
|
||||
widgetSize = this[SettingsKeys.widgetSize],
|
||||
defaultView = this[SettingsKeys.defaultView],
|
||||
quickSwitchConfig = this[SettingsKeys.quickSwitchConfig],
|
||||
monthViewStyle = this[SettingsKeys.monthViewStyle],
|
||||
@@ -283,13 +280,8 @@ class SettingsViewModel @Inject constructor(
|
||||
|
||||
fun setAgendaScreenRange(range: AgendaRange) = save { setAgendaScreenRange(range) }
|
||||
|
||||
fun setAgendaWidgetRange(range: AgendaRange) = save { setAgendaWidgetRange(range) }
|
||||
|
||||
fun setAgendaShowRangeBar(enabled: Boolean) = save { setAgendaShowRangeBar(enabled) }
|
||||
|
||||
/** The size the agenda widget draws its text at (#51); the month widget sizes itself (#103). */
|
||||
fun setWidgetSize(size: WidgetSize) = save { setWidgetSize(size) }
|
||||
|
||||
fun setAgendaShowToday(enabled: Boolean) = save { setAgendaShowToday(enabled) }
|
||||
|
||||
fun setTimeFormat(pref: TimeFormatPref) = save { setTimeFormat(pref) }
|
||||
|
||||
@@ -10,7 +10,9 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -18,56 +20,47 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.metricsFor
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.GroupedHint
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import kotlin.math.roundToInt
|
||||
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.calendula.widget.config.PlacedWidget
|
||||
import de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity
|
||||
import de.jeanlucmakiola.calendula.widget.config.WidgetKind
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import de.jeanlucmakiola.calendula.widget.config.backgroundLabel
|
||||
import de.jeanlucmakiola.calendula.widget.config.label
|
||||
import de.jeanlucmakiola.calendula.widget.config.placedWidgets
|
||||
import de.jeanlucmakiola.calendula.widget.config.themeLabel
|
||||
|
||||
/**
|
||||
* Widgets & tiles (#69): the home-screen widgets' settings and the Quick
|
||||
* Settings tile shortcut. The agenda widget keeps its own range and text size,
|
||||
* separate from the Agenda screen's range in [ViewsScreen].
|
||||
* Widgets & tiles (#69): the placed home-screen widgets, each opening its own
|
||||
* config screen (#292), and the Quick Settings tile shortcut.
|
||||
*/
|
||||
@Composable
|
||||
internal fun WidgetsScreen(
|
||||
state: SettingsUiState,
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
visible: Boolean = true,
|
||||
) {
|
||||
var showAgendaWidgetRange by remember { mutableStateOf(false) }
|
||||
var showWidgetSize by remember { mutableStateOf(false) }
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_widgets),
|
||||
onBack = onBack,
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
GroupedHint(stringResource(R.string.settings_widgets_hint))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_agenda_widget_range),
|
||||
summary = agendaRangeLabel(state.agendaWidgetRange),
|
||||
position = Position.Top,
|
||||
onClick = { showAgendaWidgetRange = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_widget_size),
|
||||
summary = widgetSizeLabel(state.widgetSize),
|
||||
position = Position.Bottom,
|
||||
onClick = { showWidgetSize = true },
|
||||
GroupedHint(
|
||||
stringResource(
|
||||
// The launcher's "Edit widget" action only exists from Android 12.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) R.string.settings_widgets_hint
|
||||
else R.string.settings_widgets_hint_legacy,
|
||||
),
|
||||
)
|
||||
GroupedSectionHeader(stringResource(R.string.settings_widgets_placed))
|
||||
PlacedWidgetRows()
|
||||
|
||||
// The add-tile prompt is API 33+; below that the tile is still
|
||||
// addable from the QS editor, so the row just doesn't appear.
|
||||
@@ -76,30 +69,44 @@ internal fun WidgetsScreen(
|
||||
QuickSettingsTileRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showAgendaWidgetRange) {
|
||||
AgendaRangePicker(
|
||||
title = stringResource(R.string.settings_agenda_widget_range),
|
||||
description = stringResource(R.string.settings_agenda_widget_range_hint),
|
||||
selected = state.agendaWidgetRange,
|
||||
weekStart = state.weekStart.resolveFirstDay(currentLocale()),
|
||||
onSelect = viewModel::setAgendaWidgetRange,
|
||||
onDismiss = { showAgendaWidgetRange = false },
|
||||
)
|
||||
@Composable
|
||||
private fun PlacedWidgetRows() {
|
||||
val context = LocalContext.current
|
||||
// Read on every resume, the first included, so a widget added, removed or
|
||||
// edited meanwhile shows up.
|
||||
var refresh by remember { mutableIntStateOf(0) }
|
||||
LifecycleResumeEffect(Unit) {
|
||||
refresh++
|
||||
onPauseOrDispose {}
|
||||
}
|
||||
if (showWidgetSize) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_widget_size),
|
||||
header = { GroupedHint(stringResource(R.string.settings_widget_size_hint), verticalPadding = 8.dp) },
|
||||
predictiveBack = true,
|
||||
options = WidgetSize.entries,
|
||||
selected = state.widgetSize,
|
||||
label = { widgetSizeLabel(it) },
|
||||
// No live preview: the widget is Glance/RemoteViews, so a Compose
|
||||
// mock-up would be a second implementation free to drift.
|
||||
summary = { widgetSizeSummary(it) },
|
||||
onSelect = viewModel::setWidgetSize,
|
||||
onDismiss = { showWidgetSize = false },
|
||||
val widgets by produceState<List<PlacedWidget>?>(initialValue = null, refresh) {
|
||||
if (refresh > 0) value = withContext(Dispatchers.IO) { context.placedWidgets() }
|
||||
}
|
||||
val placed = widgets ?: return
|
||||
if (placed.isEmpty()) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_widgets_none),
|
||||
position = Position.Alone,
|
||||
dimmed = true,
|
||||
)
|
||||
return
|
||||
}
|
||||
val kindCounts = placed.groupingBy { it.kind }.eachCount()
|
||||
val kindSeen = mutableMapOf<WidgetKind, Int>()
|
||||
placed.forEachIndexed { index, widget ->
|
||||
val number = kindSeen.merge(widget.kind, 1, Int::plus)!!
|
||||
val label = stringResource(widget.kind.label)
|
||||
GroupedRow(
|
||||
title = if (kindCounts.getValue(widget.kind) > 1) stringResource(R.string.widget_numbered, label, number) else label,
|
||||
summary = stringResource(
|
||||
R.string.widget_appearance_summary,
|
||||
backgroundLabel(widget.appearance.background),
|
||||
themeLabel(widget.appearance.theme),
|
||||
),
|
||||
position = positionOf(index, placed.size),
|
||||
onClick = { context.startActivity(WidgetConfigActivity.intent(context, widget.appWidgetId)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -131,24 +138,3 @@ private fun requestAddQsTile(context: Context) {
|
||||
context.mainExecutor,
|
||||
) { /* result code unused — the system surfaces its own feedback */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* What a size step does, as a percentage of the smallest one's event text.
|
||||
* Derived from the widget's own [metricsFor] table rather than hardcoded.
|
||||
*/
|
||||
@Composable
|
||||
private fun widgetSizeSummary(size: WidgetSize): String {
|
||||
val base = metricsFor(WidgetSize.SMALL).eventTitle.value
|
||||
val percent = (metricsFor(size).eventTitle.value / base * 100f).roundToInt()
|
||||
return stringResource(R.string.settings_widget_size_summary, percent)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun widgetSizeLabel(size: WidgetSize): String = stringResource(
|
||||
when (size) {
|
||||
WidgetSize.SMALL -> R.string.settings_widget_size_small
|
||||
WidgetSize.MEDIUM -> R.string.settings_widget_size_medium
|
||||
WidgetSize.LARGE -> R.string.settings_widget_size_large
|
||||
WidgetSize.EXTRA_LARGE -> R.string.settings_widget_size_extra_large
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
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.stringPreferencesKey
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.appwidget.state.getAppWidgetState
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.floret.prefs.toEnum
|
||||
|
||||
/** A widget's own light/dark choice (#292); [FOLLOW_APP] tracks the app's theme setting. */
|
||||
enum class WidgetTheme { FOLLOW_APP, LIGHT, DARK }
|
||||
|
||||
/** How a widget looks, set per placed widget in its config screen (#292). */
|
||||
data class WidgetAppearance(
|
||||
val background: WidgetBackground = WidgetBackground.TINTED,
|
||||
val theme: WidgetTheme = WidgetTheme.FOLLOW_APP,
|
||||
)
|
||||
|
||||
/** What the agenda widget lists, per placed widget. */
|
||||
data class AgendaWidgetOptions(
|
||||
val range: AgendaRange,
|
||||
val textSize: WidgetSize,
|
||||
val showToday: Boolean,
|
||||
val pastDisplay: PastEventDisplay,
|
||||
)
|
||||
|
||||
internal fun WidgetTheme.resolve(app: ThemeMode): WidgetThemeMode = when (this) {
|
||||
WidgetTheme.LIGHT -> WidgetThemeMode.LIGHT
|
||||
WidgetTheme.DARK -> WidgetThemeMode.DARK
|
||||
WidgetTheme.FOLLOW_APP -> when (app) {
|
||||
ThemeMode.SYSTEM -> WidgetThemeMode.SYSTEM
|
||||
ThemeMode.LIGHT -> WidgetThemeMode.LIGHT
|
||||
ThemeMode.DARK, ThemeMode.BLACK -> WidgetThemeMode.DARK
|
||||
}
|
||||
}
|
||||
|
||||
private val BackgroundKey = stringPreferencesKey("cfg_background")
|
||||
private val ThemeKey = stringPreferencesKey("cfg_theme")
|
||||
private val AgendaRangeKey = stringPreferencesKey("cfg_agenda_range")
|
||||
private val TextSizeKey = stringPreferencesKey("cfg_text_size")
|
||||
private val ShowTodayKey = booleanPreferencesKey("cfg_show_today")
|
||||
private val PastDisplayKey = stringPreferencesKey("cfg_past_display")
|
||||
|
||||
internal val Preferences.widgetAppearance: WidgetAppearance
|
||||
get() = WidgetAppearance(
|
||||
background = this[BackgroundKey].toEnum(WidgetBackground.TINTED),
|
||||
theme = this[ThemeKey].toEnum(WidgetTheme.FOLLOW_APP),
|
||||
)
|
||||
|
||||
/**
|
||||
* This widget's agenda options. A widget placed before per-widget options
|
||||
* existed has none stored, and keeps following the app-wide values in
|
||||
* [defaults] until it is configured.
|
||||
*/
|
||||
internal fun Preferences.agendaWidgetOptions(defaults: AgendaWidgetOptions) = AgendaWidgetOptions(
|
||||
range = parseAgendaRange(this[AgendaRangeKey], defaults.range),
|
||||
textSize = this[TextSizeKey].toEnum(defaults.textSize),
|
||||
showToday = this[ShowTodayKey] ?: defaults.showToday,
|
||||
pastDisplay = this[PastDisplayKey].toEnum(defaults.pastDisplay),
|
||||
)
|
||||
|
||||
/** Whether this widget has agenda options of its own, rather than following the app's. */
|
||||
internal val Preferences.hasAgendaOptions: Boolean get() = this[AgendaRangeKey] != null
|
||||
|
||||
internal fun MutablePreferences.putAppearance(appearance: WidgetAppearance) {
|
||||
this[BackgroundKey] = appearance.background.name
|
||||
this[ThemeKey] = appearance.theme.name
|
||||
}
|
||||
|
||||
internal fun MutablePreferences.putAgendaOptions(options: AgendaWidgetOptions) {
|
||||
this[AgendaRangeKey] = options.range.storageValue()
|
||||
this[TextSizeKey] = options.textSize.name
|
||||
this[ShowTodayKey] = options.showToday
|
||||
this[PastDisplayKey] = options.pastDisplay.name
|
||||
}
|
||||
|
||||
internal suspend fun Context.widgetState(id: GlanceId): Preferences =
|
||||
getAppWidgetState(this, PreferencesGlanceStateDefinition, id)
|
||||
@@ -18,6 +18,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.agendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.floret.prefs.get
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
@@ -46,26 +47,46 @@ import kotlin.time.Instant
|
||||
*/
|
||||
internal const val MONTH_WIDGET_PAD_MONTHS = 3
|
||||
|
||||
/** Every setting the widgets draw with: what the loads read, and what [WidgetRefreshCoordinator] watches. */
|
||||
/**
|
||||
* Every app setting the widgets draw with: what the loads read, and what
|
||||
* [WidgetRefreshCoordinator] watches. The agenda fields are only the defaults
|
||||
* for a widget that has no options of its own yet.
|
||||
*/
|
||||
internal data class WidgetSettings(
|
||||
val soften: Boolean,
|
||||
val weekStart: WeekStartPref,
|
||||
val timeFormat: TimeFormatPref,
|
||||
val themeMode: ThemeMode,
|
||||
val pastDisplay: PastEventDisplay,
|
||||
val agendaRange: AgendaRange,
|
||||
val widgetSize: WidgetSize,
|
||||
val showToday: Boolean,
|
||||
) {
|
||||
/** The part the month widget draws with; the rest is agenda-only. */
|
||||
val month: Pair<Boolean, WeekStartPref> get() = soften to weekStart
|
||||
/** What every widget draws with; the agenda defaults aside. */
|
||||
val shared: SharedWidgetSettings get() = SharedWidgetSettings(soften, weekStart, timeFormat, themeMode)
|
||||
|
||||
val agendaDefaults: AgendaWidgetOptions
|
||||
get() = AgendaWidgetOptions(agendaRange, widgetSize, showToday, pastDisplay)
|
||||
}
|
||||
|
||||
/** The [WidgetSettings] a configured widget still takes from the app. */
|
||||
internal data class SharedWidgetSettings(
|
||||
val soften: Boolean,
|
||||
val weekStart: WeekStartPref,
|
||||
val timeFormat: TimeFormatPref,
|
||||
val themeMode: ThemeMode,
|
||||
)
|
||||
|
||||
/** A widget's loaded [content], with the app theme a [WidgetTheme.FOLLOW_APP] widget resolves against. */
|
||||
internal data class Themed<out T>(val content: T, val appTheme: ThemeMode)
|
||||
|
||||
internal val SettingsPrefs.widgetSettings: Flow<WidgetSettings>
|
||||
get() = data.map {
|
||||
WidgetSettings(
|
||||
soften = it[SettingsKeys.softenCalendarColors],
|
||||
weekStart = it[SettingsKeys.weekStart],
|
||||
timeFormat = it[SettingsKeys.timeFormat],
|
||||
themeMode = it[SettingsKeys.themeMode],
|
||||
pastDisplay = it[SettingsKeys.pastEventDisplay],
|
||||
agendaRange = it[SettingsKeys.agendaWidgetRange],
|
||||
widgetSize = it[SettingsKeys.widgetSize],
|
||||
@@ -97,7 +118,7 @@ sealed interface AgendaWidgetData {
|
||||
val pastDisplay: PastEventDisplay,
|
||||
/** "Always show today" (#35): anchor today at the top even with no events left. */
|
||||
val showToday: Boolean,
|
||||
val widgetSize: WidgetSize,
|
||||
val textSize: WidgetSize,
|
||||
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
|
||||
val now: Instant,
|
||||
) : AgendaWidgetData
|
||||
@@ -121,19 +142,28 @@ sealed interface MonthWidgetSource {
|
||||
) : MonthWidgetSource
|
||||
}
|
||||
|
||||
/** [loadAgendaWidgetData] with agenda widget [id]'s own options. */
|
||||
internal suspend fun Context.loadAgendaWidget(id: GlanceId): Themed<AgendaWidgetData> {
|
||||
val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
|
||||
val options = widgetState(id).agendaWidgetOptions(settings.agendaDefaults)
|
||||
return Themed(loadAgendaWidgetData(settings, options), settings.themeMode)
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot read of the upcoming agenda for the widget. Reuses the app's
|
||||
* [agendaRange] window and [groupAgendaDays] grouping, and the repository's
|
||||
* [first]-emitted snapshot already has hidden calendars filtered out.
|
||||
*/
|
||||
internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
||||
internal suspend fun Context.loadAgendaWidgetData(
|
||||
settings: WidgetSettings,
|
||||
options: AgendaWidgetOptions,
|
||||
): AgendaWidgetData {
|
||||
if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission
|
||||
val zone = systemZone()
|
||||
val anchor = today(zone)
|
||||
val ep = widgetEntryPoint()
|
||||
val settings = ep.settingsPrefs().widgetSettings.first()
|
||||
val weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault())
|
||||
val dayCount = settings.agendaRange.dayCount(anchor, weekStart)
|
||||
val dayCount = options.range.dayCount(anchor, weekStart)
|
||||
val window = agendaRange(anchor, dayCount - 1, zone)
|
||||
val windowEnd = anchor.plus(dayCount - 1, DateTimeUnit.DAY)
|
||||
val instances = ep.calendarRepository().instances(window).first()
|
||||
@@ -142,9 +172,9 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
||||
days = groupAgendaDays(anchor, windowEnd, instances, zone),
|
||||
is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(this)),
|
||||
soften = settings.soften,
|
||||
pastDisplay = settings.pastDisplay,
|
||||
showToday = settings.showToday,
|
||||
widgetSize = settings.widgetSize,
|
||||
pastDisplay = options.pastDisplay,
|
||||
showToday = options.showToday,
|
||||
textSize = options.textSize,
|
||||
now = Clock.System.now(),
|
||||
)
|
||||
}
|
||||
@@ -167,30 +197,35 @@ internal fun monthWindowCenter(index: Int): Int = Math.floorDiv(index, 3) * 3 +
|
||||
* stale.
|
||||
*/
|
||||
private object MonthSourceCache {
|
||||
private class Entry(val center: Int, val stamp: Long, val source: MonthWidgetSource.Ready)
|
||||
private class Entry(val center: Int, val stamp: Long, val source: Themed<MonthWidgetSource.Ready>)
|
||||
|
||||
private val entries = ConcurrentHashMap<GlanceId, Entry>()
|
||||
|
||||
fun get(id: GlanceId, center: Int, stamp: Long): MonthWidgetSource.Ready? =
|
||||
entries[id]?.takeIf { it.center == center && it.stamp == stamp && it.source.today == today(systemZone()) }
|
||||
fun get(id: GlanceId, center: Int, stamp: Long): Themed<MonthWidgetSource>? =
|
||||
entries[id]?.takeIf { it.center == center && it.stamp == stamp && it.source.content.today == today(systemZone()) }
|
||||
?.source
|
||||
|
||||
fun put(id: GlanceId, center: Int, stamp: Long, source: MonthWidgetSource) {
|
||||
if (source is MonthWidgetSource.Ready) entries[id] = Entry(center, stamp, source)
|
||||
fun put(id: GlanceId, center: Int, stamp: Long, source: Themed<MonthWidgetSource>) {
|
||||
val ready = source.content as? MonthWidgetSource.Ready ?: return
|
||||
entries[id] = Entry(center, stamp, Themed(ready, source.appTheme))
|
||||
}
|
||||
}
|
||||
|
||||
/** [loadMonthWidgetSource] for widget [id], served from [MonthSourceCache] while its [stamp] holds. */
|
||||
internal suspend fun Context.loadMonthWidgetSource(id: GlanceId, centerIndex: Int, stamp: Long): MonthWidgetSource =
|
||||
/** [loadThemedMonthSource] for widget [id], served from [MonthSourceCache] while its [stamp] holds. */
|
||||
internal suspend fun Context.loadCachedMonthSource(id: GlanceId, centerIndex: Int, stamp: Long): Themed<MonthWidgetSource> =
|
||||
MonthSourceCache.get(id, centerIndex, stamp)
|
||||
?: loadMonthWidgetSource(centerIndex).also { MonthSourceCache.put(id, centerIndex, stamp, it) }
|
||||
?: loadThemedMonthSource(centerIndex).also { MonthSourceCache.put(id, centerIndex, stamp, it) }
|
||||
|
||||
/** Read backing the month widget's grid for the window around [centerIndex]. */
|
||||
private suspend fun Context.loadMonthWidgetSource(centerIndex: Int): MonthWidgetSource {
|
||||
/** Read backing the month widget's grid for the window around [centerIndex], with the app theme. */
|
||||
internal suspend fun Context.loadThemedMonthSource(centerIndex: Int): Themed<MonthWidgetSource> {
|
||||
val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
|
||||
return Themed(queryMonthSource(centerIndex, settings), settings.themeMode)
|
||||
}
|
||||
|
||||
private suspend fun Context.queryMonthSource(centerIndex: Int, settings: WidgetSettings): MonthWidgetSource {
|
||||
if (!hasCalendarPermission()) return MonthWidgetSource.NeedsPermission
|
||||
val zone = systemZone()
|
||||
val ep = widgetEntryPoint()
|
||||
val settings = ep.settingsPrefs().widgetSettings.first()
|
||||
val months = (centerIndex - MONTH_WIDGET_PAD_MONTHS)..(centerIndex + MONTH_WIDGET_PAD_MONTHS)
|
||||
// Padded by the grid's spill into the neighbouring months.
|
||||
val from = firstDayOfMonthIndex(months.first)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.SystemClock
|
||||
@@ -14,6 +16,8 @@ import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
|
||||
import de.jeanlucmakiola.floret.glance.WidgetRefresher
|
||||
import de.jeanlucmakiola.floret.glance.WidgetRollover
|
||||
import de.jeanlucmakiola.floret.glance.updatePlaced
|
||||
import de.jeanlucmakiola.floret.glance.updateWithRefresh
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
@@ -63,6 +67,18 @@ internal suspend fun redrawWidgets(context: Context) {
|
||||
private suspend fun redrawAgendaWidgets(context: Context) =
|
||||
AgendaWidget().updatePlaced(context, AgendaWidgetReceiver::class.java, bumpRefreshStamp = true)
|
||||
|
||||
/** Redraws only the agenda widgets with no options of their own, which still follow the app's. */
|
||||
private suspend fun redrawUnconfiguredAgendaWidgets(context: Context) {
|
||||
val appContext = context.applicationContext
|
||||
val ids = AppWidgetManager.getInstance(appContext)
|
||||
?.getAppWidgetIds(ComponentName(appContext, AgendaWidgetReceiver::class.java)) ?: return
|
||||
val glanceManager = GlanceAppWidgetManager(appContext)
|
||||
ids.forEach { appWidgetId ->
|
||||
val glanceId = glanceManager.getGlanceIdBy(appWidgetId)
|
||||
if (!appContext.widgetState(glanceId).hasAgendaOptions) AgendaWidget().updateWithRefresh(appContext, glanceId)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun redrawMonthWidgets(context: Context) =
|
||||
MonthWidget().updatePlaced(context, MonthWidgetReceiver::class.java, bumpRefreshStamp = true)
|
||||
|
||||
@@ -80,8 +96,9 @@ internal fun isProviderEcho(): Boolean {
|
||||
* Redraws the widgets on the app's own calendar writes, visibility changes and
|
||||
* the settings they draw with, so those don't wait for `PROVIDER_CHANGED`,
|
||||
* which arrives seconds late or not at all (#420, #229). Changes from outside
|
||||
* the app are [WidgetUpdateReceiver]'s. A setting only the agenda widget draws
|
||||
* with leaves the month widget alone. Started once from `CalendulaApp`.
|
||||
* the app are [WidgetUpdateReceiver]'s. The app's agenda options only redraw
|
||||
* agenda widgets that have none of their own (#292). Started once from
|
||||
* `CalendulaApp`.
|
||||
*/
|
||||
@Singleton
|
||||
class WidgetRefreshCoordinator @Inject constructor(
|
||||
@@ -96,14 +113,20 @@ class WidgetRefreshCoordinator @Inject constructor(
|
||||
fun start() {
|
||||
if (!started.compareAndSet(false, true)) return
|
||||
val agenda = WidgetRefresher(scope) { redrawAgendaWidgets(context) }
|
||||
val unconfiguredAgenda = WidgetRefresher(scope) { redrawUnconfiguredAgendaWidgets(context) }
|
||||
val month = WidgetRefresher(scope) { redrawMonthWidgets(context) }
|
||||
watch(repository.localChanges) {
|
||||
lastLocalChange.set(SystemClock.elapsedRealtime())
|
||||
agenda.request()
|
||||
month.request()
|
||||
}
|
||||
watch(prefs.widgetSettings.drop(1)) { agenda.request() }
|
||||
watch(prefs.widgetSettings.map { it.month }.distinctUntilChanged().drop(1)) { month.request() }
|
||||
watch(prefs.widgetSettings.map { it.shared }.distinctUntilChanged().drop(1)) {
|
||||
agenda.request()
|
||||
month.request()
|
||||
}
|
||||
watch(prefs.widgetSettings.map { it.agendaDefaults }.distinctUntilChanged().drop(1)) {
|
||||
unconfiguredAgenda.request()
|
||||
}
|
||||
}
|
||||
|
||||
private fun watch(changes: Flow<*>, onChange: () -> Unit) {
|
||||
|
||||
@@ -5,23 +5,34 @@ import androidx.glance.text.TextDecoration
|
||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback
|
||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaLightFallback
|
||||
import de.jeanlucmakiola.floret.glance.FloretGlanceTheme
|
||||
import de.jeanlucmakiola.floret.glance.WidgetSurface
|
||||
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
|
||||
/**
|
||||
* Glance equivalent of `CalendulaTheme`: Material You on API 31+, the app's
|
||||
* hand-tuned fallback schemes below that, following the system's light/dark.
|
||||
* hand-tuned fallback schemes below that, in the light/dark [mode] the widget
|
||||
* was configured with (#292).
|
||||
*/
|
||||
@Composable
|
||||
fun CalendulaGlanceTheme(content: @Composable () -> Unit) {
|
||||
fun CalendulaGlanceTheme(mode: WidgetThemeMode, content: @Composable () -> Unit) {
|
||||
FloretGlanceTheme(
|
||||
lightScheme = CalendulaLightFallback,
|
||||
darkScheme = CalendulaDarkFallback,
|
||||
dynamicColor = true,
|
||||
mode = WidgetThemeMode.SYSTEM,
|
||||
mode = mode,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
/** A widget's root: its configured theme and background around [content]. */
|
||||
@Composable
|
||||
internal fun CalendulaWidgetRoot(appearance: WidgetAppearance, appTheme: ThemeMode, content: @Composable () -> Unit) {
|
||||
CalendulaGlanceTheme(appearance.theme.resolve(appTheme)) {
|
||||
WidgetSurface(background = appearance.background, content = content)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Glance's counterpart to the app's `declinedDecoration`: a declined invitation
|
||||
* reads the same on the home screen as it does inside the app (#180).
|
||||
|
||||
@@ -6,17 +6,11 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||
|
||||
/**
|
||||
* Horizontal layout constants for an agenda event row. These don't scale with the
|
||||
* size step, since a wider stripe or gap would eat title width, but [TEXT_INDENT]
|
||||
* is derived from them so non-event text can't drift out of alignment.
|
||||
*/
|
||||
internal val ROW_H_PAD = 4.dp
|
||||
internal val STRIPE_WIDTH = 5.dp
|
||||
internal val STRIPE_GAP = 10.dp
|
||||
/** Horizontal inset of a row's text inside its card; doesn't scale, so it never eats title width. */
|
||||
internal val ROW_H_PAD = 12.dp
|
||||
|
||||
/** Left indent that lines non-event text up with the event title column. */
|
||||
internal val TEXT_INDENT = ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP
|
||||
/** Space between the date-pill gutter and a day's card. */
|
||||
internal val GUTTER_GAP = 6.dp
|
||||
|
||||
/**
|
||||
* Every size the agenda widget varies by [WidgetSize]. Values that genuinely
|
||||
@@ -24,90 +18,91 @@ internal val TEXT_INDENT = ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP
|
||||
* constants rather than routing through here.
|
||||
*/
|
||||
internal data class AgendaMetrics(
|
||||
val title: TextUnit, // header "Upcoming"
|
||||
val dayHeader: TextUnit, // day label ("Today · …")
|
||||
val pillWeekday: TextUnit, // date pill weekday ("Thu")
|
||||
val pillDay: TextUnit, // date pill day number
|
||||
val eventTitle: TextUnit, // event title line
|
||||
val eventTime: TextUnit, // time/location line
|
||||
val placeholder: TextUnit, // "no more events today" (#35)
|
||||
val message: TextUnit, // empty/permission centred message
|
||||
val stripeH: Dp, // coloured event stripe height
|
||||
val iconImage: Dp, // header action icon glyph
|
||||
val iconBox: Dp, // header action touch target
|
||||
val contentH: Dp, // an event row's two text lines
|
||||
val pillWidth: Dp, // date pill / gutter width
|
||||
val rowVPad: Dp, // event row vertical padding
|
||||
val dayHeaderTopPad: Dp, // space above a day header
|
||||
val dayGap: Dp, // space between two days' cards
|
||||
val monthLabel: TextUnit, // month name above a new month's first day
|
||||
) {
|
||||
/** One event row's height, shared by the date pill beside it and the placeholder row. */
|
||||
val rowHeight: Dp get() = contentH + rowVPad * 2
|
||||
|
||||
/**
|
||||
* Resolves the stripe height against the user's system font scale: the stripe
|
||||
* is a [Dp] while the lines beside it are `sp`, so without this the text
|
||||
* outgrows it at large font settings.
|
||||
* Resolves the row content height and pill width against the user's system font
|
||||
* scale: both are [Dp] while the text in and beside them is `sp`, so without
|
||||
* this the text outgrows them at large font settings.
|
||||
*/
|
||||
fun scaledForFont(fontScale: Float): AgendaMetrics =
|
||||
if (fontScale == 1f) this else copy(stripeH = stripeH * fontScale)
|
||||
if (fontScale == 1f) this else copy(contentH = contentH * fontScale, pillWidth = pillWidth * fontScale)
|
||||
}
|
||||
|
||||
/*
|
||||
* Type sizes are anchored to the Material 3 type scale, with two deviations:
|
||||
* Type sizes are anchored to the Material 3 type scale, with one deviation:
|
||||
*
|
||||
* ‡ SMALL's 13sp day header is off-scale, held there so SMALL reproduces the
|
||||
* widget's original constants verbatim (#51).
|
||||
* † Above Title Medium the M3 scale jumps 16 → 22 → 24, too coarse for four
|
||||
* steps; interpolated values are used where a role would force a ≥1.4x jump.
|
||||
*/
|
||||
|
||||
private val SMALL_METRICS = AgendaMetrics(
|
||||
title = 16.sp, // M3 Title Medium
|
||||
dayHeader = 13.sp, // ‡ off-scale, holds the #51 baseline
|
||||
pillWeekday = 11.sp,
|
||||
pillDay = 16.sp,
|
||||
eventTitle = 14.sp, // M3 Body Medium
|
||||
eventTime = 12.sp, // M3 Body Small
|
||||
placeholder = 14.sp, // M3 Body Medium
|
||||
message = 14.sp, // M3 Body Medium
|
||||
stripeH = 36.dp,
|
||||
iconImage = 22.dp,
|
||||
iconBox = 40.dp,
|
||||
contentH = 36.dp,
|
||||
pillWidth = 40.dp,
|
||||
rowVPad = 4.dp,
|
||||
dayHeaderTopPad = 10.dp,
|
||||
dayGap = 8.dp,
|
||||
monthLabel = 12.sp,
|
||||
)
|
||||
|
||||
private val MEDIUM_METRICS = AgendaMetrics(
|
||||
title = 18.sp, // †
|
||||
dayHeader = 14.sp, // M3 Title Small
|
||||
pillWeekday = 12.sp,
|
||||
pillDay = 18.sp,
|
||||
eventTitle = 16.sp, // M3 Body Large
|
||||
eventTime = 14.sp, // M3 Body Medium
|
||||
placeholder = 16.sp, // M3 Body Large
|
||||
message = 16.sp, // M3 Body Large
|
||||
stripeH = 40.dp,
|
||||
iconImage = 24.dp,
|
||||
iconBox = 44.dp,
|
||||
contentH = 40.dp,
|
||||
pillWidth = 44.dp,
|
||||
rowVPad = 5.dp,
|
||||
dayHeaderTopPad = 11.dp,
|
||||
dayGap = 9.dp,
|
||||
monthLabel = 14.sp,
|
||||
)
|
||||
|
||||
private val LARGE_METRICS = AgendaMetrics(
|
||||
title = 20.sp, // †
|
||||
dayHeader = 16.sp, // M3 Title Medium
|
||||
pillWeekday = 12.sp,
|
||||
pillDay = 20.sp,
|
||||
eventTitle = 18.sp, // †
|
||||
eventTime = 14.sp, // M3 Body Medium — the secondary line steps more slowly
|
||||
placeholder = 18.sp, // † on purpose, so the title keeps its
|
||||
message = 18.sp, // † lead and the hierarchy survives.
|
||||
stripeH = 46.dp,
|
||||
iconImage = 26.dp,
|
||||
iconBox = 48.dp,
|
||||
contentH = 46.dp,
|
||||
pillWidth = 48.dp,
|
||||
rowVPad = 6.dp,
|
||||
dayHeaderTopPad = 12.dp,
|
||||
dayGap = 10.dp,
|
||||
monthLabel = 14.sp,
|
||||
)
|
||||
|
||||
private val EXTRA_LARGE_METRICS = AgendaMetrics(
|
||||
title = 22.sp, // M3 Title Large
|
||||
dayHeader = 18.sp, // †
|
||||
pillWeekday = 14.sp,
|
||||
pillDay = 22.sp,
|
||||
eventTitle = 20.sp, // †
|
||||
eventTime = 16.sp, // M3 Body Large
|
||||
placeholder = 20.sp, // †
|
||||
message = 20.sp, // †
|
||||
stripeH = 52.dp,
|
||||
iconImage = 28.dp,
|
||||
iconBox = 52.dp,
|
||||
contentH = 52.dp,
|
||||
pillWidth = 52.dp,
|
||||
rowVPad = 8.dp,
|
||||
dayHeaderTopPad = 14.dp,
|
||||
dayGap = 12.dp,
|
||||
monthLabel = 16.sp,
|
||||
)
|
||||
|
||||
/** Indexed by [WidgetSize.ordinal] so lookup allocates nothing per recomposition. */
|
||||
|
||||
@@ -6,19 +6,13 @@ import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.glance.ColorFilter
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.Image
|
||||
import androidx.glance.ImageProvider
|
||||
import androidx.glance.action.ActionParameters
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.SizeMode
|
||||
import androidx.glance.color.ColorProvider
|
||||
import androidx.glance.appwidget.action.ActionCallback
|
||||
import androidx.glance.appwidget.action.actionRunCallback
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.appwidget.lazy.LazyColumn
|
||||
@@ -37,6 +31,12 @@ import androidx.glance.layout.height
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.layout.size
|
||||
import androidx.glance.layout.width
|
||||
import androidx.glance.semantics.contentDescription
|
||||
import androidx.glance.semantics.semantics
|
||||
import androidx.glance.text.TextAlign
|
||||
import androidx.glance.unit.ColorProvider
|
||||
import de.jeanlucmakiola.floret.glance.WidgetHeader
|
||||
import de.jeanlucmakiola.floret.glance.WidgetHeaderAction
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
@@ -50,31 +50,45 @@ import de.jeanlucmakiola.calendula.ui.agenda.agendaTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
|
||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
||||
import de.jeanlucmakiola.calendula.widget.loadAgendaWidget
|
||||
import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetGroupedRow
|
||||
import de.jeanlucmakiola.floret.glance.themedColor
|
||||
import de.jeanlucmakiola.floret.glance.wallpaperBacking
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.glance.currentState
|
||||
import de.jeanlucmakiola.floret.glance.WidgetData
|
||||
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
|
||||
import de.jeanlucmakiola.floret.glance.refreshStamp
|
||||
import de.jeanlucmakiola.floret.glance.updateWithRefresh
|
||||
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||
import de.jeanlucmakiola.calendula.widget.today
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toJavaDayOfWeek
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days
|
||||
* of events grouped under day headers (the Google "Schedule" widget model).
|
||||
* of events, each day one card of grouped rows with its date pill beside it.
|
||||
* Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
|
||||
*/
|
||||
class AgendaWidget : GlanceAppWidget() {
|
||||
@@ -89,25 +103,40 @@ class AgendaWidget : GlanceAppWidget() {
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val stamp = refreshStamp(context, id)
|
||||
val widgetData = WidgetData(context.loadAgendaWidgetData(), stamp)
|
||||
val widgetData = WidgetData(context.loadAgendaWidget(id), stamp)
|
||||
provideContent {
|
||||
// A running session never re-runs this preamble, so every redraw
|
||||
// moves the stamp and the data reloads here instead (#420).
|
||||
val data = widgetData.current(currentRefreshStamp()) { context.loadAgendaWidgetData() }
|
||||
CalendulaGlanceTheme {
|
||||
AgendaWidgetBody(data = data)
|
||||
}
|
||||
val loaded = widgetData.current(currentRefreshStamp()) { context.loadAgendaWidget(id) }
|
||||
AgendaWidgetContent(
|
||||
data = loaded.content,
|
||||
appearance = currentState<Preferences>().widgetAppearance,
|
||||
appTheme = loaded.appTheme,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Reloads and redraws the tapped widget (header refresh button). */
|
||||
class RefreshAgendaAction : ActionCallback {
|
||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||
AgendaWidget().updateWithRefresh(context.applicationContext, glanceId)
|
||||
/**
|
||||
* The whole agenda widget, shared with its config screen's preview (#292). The
|
||||
* [preview] draws its rows as a plain column, which RemoteViews can show
|
||||
* outside a widget host on every Android version.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AgendaWidgetContent(
|
||||
data: AgendaWidgetData,
|
||||
appearance: WidgetAppearance,
|
||||
appTheme: ThemeMode,
|
||||
preview: Boolean = false,
|
||||
) {
|
||||
CalendulaWidgetRoot(appearance, appTheme) {
|
||||
AgendaWidgetBody(data = data, preview = preview)
|
||||
}
|
||||
}
|
||||
|
||||
/** Rows a preview draws: Glance's cap on a Column's children, still more than a placed widget shows. */
|
||||
private const val PREVIEW_ROWS = 10
|
||||
|
||||
/**
|
||||
* Upper bound on rows handed to the [LazyColumn], so the serialized RemoteViews
|
||||
* stays well inside the binder transaction limit regardless of range and calendar
|
||||
@@ -117,32 +146,32 @@ class RefreshAgendaAction : ActionCallback {
|
||||
*/
|
||||
private const val MAX_AGENDA_ROWS = 100
|
||||
|
||||
/** Flat row model so the [LazyColumn] can mix day headers and events. */
|
||||
/** Flat row model: one [LazyColumn] item per event or placeholder, a day's first one carrying its date pill. */
|
||||
private sealed interface AgendaRow {
|
||||
data class Header(val date: LocalDate, val today: LocalDate) : AgendaRow
|
||||
data class Event(val date: LocalDate, val event: EventInstance) : AgendaRow
|
||||
/** "Nothing left today" line under an anchored, event-less today (#35). */
|
||||
data class Placeholder(val date: LocalDate) : AgendaRow
|
||||
val date: LocalDate
|
||||
|
||||
data class Event(override val date: LocalDate, val event: EventInstance) : AgendaRow
|
||||
/** "Nothing left today" line for an anchored, event-less today (#35). */
|
||||
data class Placeholder(override val date: LocalDate) : AgendaRow
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaWidgetBody(data: AgendaWidgetData) {
|
||||
// The stripe is re-resolved against the system font scale so it tracks the
|
||||
private fun AgendaWidgetBody(data: AgendaWidgetData, preview: Boolean) {
|
||||
// The row height is re-resolved against the system font scale so it tracks the
|
||||
// sp-sized text beside it instead of drifting at large accessibility settings.
|
||||
val size = (data as? AgendaWidgetData.Ready)?.widgetSize ?: WidgetSize.SMALL
|
||||
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()
|
||||
.background(GlanceTheme.colors.surface)
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
) {
|
||||
AgendaHeader(metrics)
|
||||
Spacer(GlanceModifier.height(4.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)
|
||||
is AgendaWidgetData.Ready -> AgendaRows(data = data, metrics = metrics, preview = preview)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,6 +180,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData) {
|
||||
private fun AgendaRows(
|
||||
data: AgendaWidgetData.Ready,
|
||||
metrics: AgendaMetrics,
|
||||
preview: Boolean,
|
||||
) {
|
||||
val pastDisplay = data.pastDisplay
|
||||
// Drop finished events (and any day they leave empty) when the mode is
|
||||
@@ -177,175 +207,223 @@ private fun AgendaRows(
|
||||
} else {
|
||||
val rows = buildList {
|
||||
visibleDays.forEach { day ->
|
||||
add(AgendaRow.Header(day.date, data.today))
|
||||
if (day.events.isEmpty()) {
|
||||
add(AgendaRow.Placeholder(day.date))
|
||||
} else {
|
||||
day.events.forEach { add(AgendaRow.Event(day.date, it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
// Bound the payload, then drop a day header the cut left
|
||||
// stranded with nothing under it.
|
||||
.take(MAX_AGENDA_ROWS)
|
||||
.dropLastWhile { it is AgendaRow.Header }
|
||||
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
||||
items(rows.size) { index ->
|
||||
when (val row = rows[index]) {
|
||||
is AgendaRow.Header -> DayHeaderRow(row.date, row.today, metrics)
|
||||
is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics)
|
||||
is AgendaRow.Event -> EventRow(
|
||||
event = row.event,
|
||||
day = row.date,
|
||||
soften = data.soften,
|
||||
is24Hour = data.is24Hour,
|
||||
dimmed = pastDisplay == PastEventDisplay.DIM &&
|
||||
row.event.hasEnded(data.now),
|
||||
metrics = metrics,
|
||||
)
|
||||
}.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 = agendaCardColor()
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Where each row sits among the rows of its day, for the grouped-card shapes. */
|
||||
private fun dayPositions(rows: List<AgendaRow>): List<Position> = buildList {
|
||||
var start = 0
|
||||
for (end in 1..rows.size) {
|
||||
if (end == rows.size || rows[end].date != rows[start].date) {
|
||||
for (i in start until end) add(positionOf(i - start, end - start))
|
||||
start = end
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaHeader(metrics: AgendaMetrics) {
|
||||
val context = androidx.glance.LocalContext.current
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Tap the "Upcoming" title to open the app on the user's default view
|
||||
// (issue #20) — a null target resolves to the default home in the host.
|
||||
Text(
|
||||
text = context.getString(R.string.widget_agenda_title),
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.primary,
|
||||
fontSize = metrics.title,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
modifier = GlanceModifier
|
||||
.defaultWeight()
|
||||
.clickable(
|
||||
actionStartActivity(MainActivity.openViewIntent(context, view = null)),
|
||||
),
|
||||
)
|
||||
IconButton(
|
||||
resId = R.drawable.ic_widget_refresh,
|
||||
contentDescription = context.getString(R.string.widget_refresh),
|
||||
onClick = GlanceModifier.clickable(actionRunCallback<RefreshAgendaAction>()),
|
||||
private fun AgendaRowContent(row: AgendaRow, data: AgendaWidgetData.Ready, metrics: AgendaMetrics) {
|
||||
when (row) {
|
||||
is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics)
|
||||
is AgendaRow.Event -> EventRow(
|
||||
event = row.event,
|
||||
day = row.date,
|
||||
fill = row.fill(data),
|
||||
is24Hour = data.is24Hour,
|
||||
metrics = metrics,
|
||||
)
|
||||
IconButton(
|
||||
resId = R.drawable.ic_widget_add,
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaHeader() {
|
||||
val context = androidx.glance.LocalContext.current
|
||||
// Tap the "Upcoming" title to open the app on the user's default view
|
||||
// (issue #20) — a null target resolves to the default home in the host.
|
||||
WidgetHeader(
|
||||
title = context.getString(R.string.widget_agenda_title),
|
||||
titleAction = actionStartActivity(MainActivity.openViewIntent(context, view = null)),
|
||||
titleInset = 4.dp,
|
||||
) {
|
||||
WidgetHeaderAction(
|
||||
icon = ImageProvider(R.drawable.ic_widget_add),
|
||||
contentDescription = context.getString(R.string.widget_new_event),
|
||||
onClick = GlanceModifier.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openCreateIntent(context, today(systemZone())),
|
||||
),
|
||||
),
|
||||
metrics = metrics,
|
||||
action = actionStartActivity(MainActivity.openCreateIntent(context, today(systemZone()))),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Cards sit one tone off the root fill, and stay opaque over a wallpaper (#292). */
|
||||
@Composable
|
||||
private fun IconButton(
|
||||
resId: Int,
|
||||
contentDescription: String,
|
||||
onClick: GlanceModifier,
|
||||
metrics: AgendaMetrics,
|
||||
) {
|
||||
Box(
|
||||
modifier = GlanceModifier.size(metrics.iconBox).then(onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
provider = ImageProvider(resId),
|
||||
contentDescription = contentDescription,
|
||||
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
|
||||
modifier = GlanceModifier.size(metrics.iconImage),
|
||||
)
|
||||
}
|
||||
private fun agendaCardColor(): ColorProvider = when (LocalWidgetBackground.current) {
|
||||
WidgetBackground.SOLID -> GlanceTheme.colors.surfaceVariant
|
||||
WidgetBackground.TINTED, WidgetBackground.TRANSPARENT -> GlanceTheme.colors.widgetBackground
|
||||
}
|
||||
|
||||
/** The month name ("November", with its year outside the current one) above a new month's first day. */
|
||||
@Composable
|
||||
private fun DayHeaderRow(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) {
|
||||
val context = androidx.glance.LocalContext.current
|
||||
private fun MonthLabel(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) {
|
||||
Text(
|
||||
text = agendaDayLabel(context, date, today),
|
||||
text = monthName(date, today),
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = if (date == today) GlanceTheme.colors.primary
|
||||
else GlanceTheme.colors.onSurfaceVariant,
|
||||
fontSize = metrics.dayHeader,
|
||||
color = GlanceTheme.colors.onSurfaceVariant,
|
||||
fontSize = metrics.monthLabel,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
start = 8.dp,
|
||||
end = 8.dp,
|
||||
top = metrics.dayHeaderTopPad,
|
||||
bottom = metrics.rowVPad,
|
||||
)
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
|
||||
),
|
||||
),
|
||||
.padding(bottom = 4.dp)
|
||||
.wallpaperBacking(radius = 8.dp)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* "No more events today" under an anchored, event-less today (#35). Kept plain —
|
||||
* a muted line indented to the event titles — since the widget's event rows are
|
||||
* plain too (a stripe + text, not cards), so a card here would look out of place.
|
||||
*/
|
||||
/** A day's weekday and number in a rounded pill, filled for today; opens that day in the app. */
|
||||
@Composable
|
||||
private fun DatePill(date: LocalDate, today: LocalDate, cardColor: ColorProvider, metrics: AgendaMetrics) {
|
||||
val context = androidx.glance.LocalContext.current
|
||||
val isToday = date == today
|
||||
val container = if (isToday) GlanceTheme.colors.primary else cardColor
|
||||
val content = if (isToday) GlanceTheme.colors.onPrimary else GlanceTheme.colors.onSurface
|
||||
val label = agendaDayLabel(context, date, today)
|
||||
Column(
|
||||
modifier = GlanceModifier
|
||||
.width(metrics.pillWidth)
|
||||
.height(metrics.rowHeight)
|
||||
.cornerRadius(metrics.pillWidth / 2)
|
||||
.background(container)
|
||||
.clickable(actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Agenda)))
|
||||
.semantics { contentDescription = label },
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = weekdayShort(date),
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = content, fontSize = metrics.pillWeekday, textAlign = TextAlign.Center),
|
||||
)
|
||||
Text(
|
||||
text = date.day.toString(),
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = content,
|
||||
fontSize = metrics.pillDay,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** "No more events today" in an anchored, event-less today's card (#35), as tall as the pill beside it. */
|
||||
@Composable
|
||||
private fun PlaceholderRow(date: LocalDate, metrics: AgendaMetrics) {
|
||||
val context = androidx.glance.LocalContext.current
|
||||
Text(
|
||||
text = context.getString(R.string.agenda_no_more_today),
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.placeholder),
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
start = TEXT_INDENT,
|
||||
end = 8.dp,
|
||||
top = 2.dp,
|
||||
bottom = metrics.rowVPad + 2.dp,
|
||||
)
|
||||
.height(metrics.rowHeight)
|
||||
.padding(horizontal = ROW_H_PAD)
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
|
||||
),
|
||||
),
|
||||
)
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
Text(
|
||||
text = context.getString(R.string.agenda_no_more_today),
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.placeholder),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 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 = EventDimAlpha) else it
|
||||
}
|
||||
return EventFill(day = tone(dark = false), night = tone(dark = true))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventRow(
|
||||
event: EventInstance,
|
||||
day: LocalDate,
|
||||
soften: Boolean,
|
||||
fill: EventFill,
|
||||
is24Hour: Boolean,
|
||||
dimmed: Boolean,
|
||||
metrics: AgendaMetrics,
|
||||
) {
|
||||
val context = androidx.glance.LocalContext.current
|
||||
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||
// Glance has no generic alpha modifier, so dim by fading the colour stripe and
|
||||
// dropping both text lines to the lower-emphasis on-surface-variant tone.
|
||||
fun stripe(dark: Boolean) = eventAccent(event.color, dark, soften).let {
|
||||
if (dimmed) it.copy(alpha = EventDimAlpha) else it
|
||||
}
|
||||
val stripeColor = ColorProvider(day = stripe(dark = false), night = stripe(dark = true))
|
||||
val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface
|
||||
Row(
|
||||
// Ink is picked against the opaque fill, then carries the dim along with it.
|
||||
fun ink(fill: Color, alpha: Float) = eventInk(fill.copy(alpha = 1f), alpha * fill.alpha)
|
||||
val titleInk = themedColor(day = ink(fill.day, 1f), night = ink(fill.night, 1f))
|
||||
val timeInk = themedColor(
|
||||
day = ink(fill.day, SECONDARY_INK_ALPHA),
|
||||
night = ink(fill.night, SECONDARY_INK_ALPHA),
|
||||
)
|
||||
Column(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = ROW_H_PAD, vertical = metrics.rowVPad)
|
||||
.height(metrics.rowHeight)
|
||||
.padding(horizontal = ROW_H_PAD)
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openEventIntent(
|
||||
@@ -359,30 +437,21 @@ private fun EventRow(
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.width(STRIPE_WIDTH)
|
||||
.height(metrics.stripeH)
|
||||
.cornerRadius(3.dp)
|
||||
.background(stripeColor),
|
||||
) {}
|
||||
Spacer(GlanceModifier.width(STRIPE_GAP))
|
||||
Column(modifier = GlanceModifier.defaultWeight()) {
|
||||
Text(
|
||||
text = title,
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = titleColor,
|
||||
fontSize = metrics.eventTitle,
|
||||
textDecoration = glanceDeclinedDecoration(event.isDeclined),
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text = eventTimeSummary(context, event, day, is24Hour),
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.eventTime),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = title,
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = titleInk,
|
||||
fontSize = metrics.eventTitle,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textDecoration = glanceDeclinedDecoration(event.isDeclined),
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text = eventTimeSummary(context, event, day, is24Hour),
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = timeInk, fontSize = metrics.eventTime),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,6 +465,7 @@ private fun WidgetMessage(resId: Int, metrics: AgendaMetrics) {
|
||||
Text(
|
||||
text = context.getString(resId),
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.message),
|
||||
modifier = GlanceModifier.wallpaperBacking().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -417,6 +487,16 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate):
|
||||
return if (relative != null) "$relative · $formatted" else formatted
|
||||
}
|
||||
|
||||
private fun monthName(date: LocalDate, today: LocalDate): String = formatCalendarTitle(
|
||||
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, 1),
|
||||
locale = Locale.getDefault(),
|
||||
currentYear = today.year,
|
||||
skeleton = "LLLL",
|
||||
)
|
||||
|
||||
private fun weekdayShort(date: LocalDate): String =
|
||||
date.dayOfWeek.toJavaDayOfWeek().getDisplayName(java.time.format.TextStyle.SHORT, Locale.getDefault())
|
||||
|
||||
private fun eventTimeSummary(
|
||||
context: Context,
|
||||
event: EventInstance,
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.syncWidgetRollover
|
||||
import de.jeanlucmakiola.floret.glance.reloadOnUpdate
|
||||
import de.jeanlucmakiola.floret.glance.restoreState
|
||||
|
||||
/**
|
||||
* Host-facing receiver for the agenda widget. Declared in the manifest with the
|
||||
@@ -31,6 +32,11 @@ class AgendaWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
syncWidgetRollover(context)
|
||||
}
|
||||
|
||||
/** A backup restore or device transfer renumbered the widgets: keep each one's settings (#292). */
|
||||
override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) {
|
||||
restoreState(context, oldWidgetIds, newWidgetIds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-heal on the system's own `updatePeriodMillis` wake-up — see
|
||||
* [de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver.onUpdate].
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package de.jeanlucmakiola.calendula.widget.config
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
||||
import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
||||
import de.jeanlucmakiola.calendula.widget.widgetState
|
||||
|
||||
/** One of the app's widgets on the home screen, for the settings list. */
|
||||
data class PlacedWidget(val appWidgetId: Int, val kind: WidgetKind, val appearance: WidgetAppearance)
|
||||
|
||||
/**
|
||||
* Every placed widget, agenda ones first, each in placement order. An id the
|
||||
* host bound but never laid out (a placement it abandoned without freeing the
|
||||
* id) has no size yet and is left out, as is one removed while this reads.
|
||||
*/
|
||||
suspend fun Context.placedWidgets(): List<PlacedWidget> {
|
||||
val manager = AppWidgetManager.getInstance(this) ?: return emptyList()
|
||||
val glanceManager = GlanceAppWidgetManager(this)
|
||||
return WidgetKind.entries.flatMap { kind ->
|
||||
manager.getAppWidgetIds(ComponentName(this, kind.receiver)).sorted().mapNotNull { id ->
|
||||
if (manager.getAppWidgetOptions(id).getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) <= 0) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
val glanceId = runCatching { glanceManager.getGlanceIdBy(id) }.getOrNull() ?: return@mapNotNull null
|
||||
PlacedWidget(id, kind, widgetState(glanceId).widgetAppearance)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package de.jeanlucmakiola.calendula.widget.config
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
|
||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
||||
import de.jeanlucmakiola.calendula.ui.theme.WindowThemeCache
|
||||
import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography
|
||||
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.floret.prefs.get
|
||||
import de.jeanlucmakiola.floret.prefs.isDark
|
||||
import de.jeanlucmakiola.floret.prefs.isPureBlack
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* A widget's config screen (#292): opened by the launcher when the widget is
|
||||
* placed, from its "Edit widget" action on Android 12+, and from the app's
|
||||
* widget settings. Shows over the wallpaper, so a transparent widget previews
|
||||
* against the real home screen.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class WidgetConfigActivity : ComponentActivity() {
|
||||
|
||||
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||
|
||||
private val viewModel: WidgetConfigViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val appWidgetId = viewModel.appWidgetId
|
||||
// Backing out of a fresh placement leaves no unconfigured widget behind.
|
||||
setResult(RESULT_CANCELED, result(appWidgetId))
|
||||
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
enableEdgeToEdge()
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
viewModel.events.collect { event ->
|
||||
when (event) {
|
||||
WidgetConfigEvent.SAVED -> {
|
||||
setResult(RESULT_OK, result(appWidgetId))
|
||||
finish()
|
||||
}
|
||||
WidgetConfigEvent.SAVE_FAILED ->
|
||||
Toast.makeText(this@WidgetConfigActivity, R.string.widget_config_save_failed, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val fontSettings = settingsPrefs.data.map {
|
||||
AppFontSettings(
|
||||
brand = it[SettingsKeys.brandFont],
|
||||
plain = it[SettingsKeys.plainFont],
|
||||
brandStamp = it[SettingsKeys.brandFontStamp],
|
||||
plainStamp = it[SettingsKeys.plainFontStamp],
|
||||
)
|
||||
}
|
||||
// Seeded from MainActivity's mirrored copy, so an app theme override
|
||||
// shows from the first frame.
|
||||
val cachedTheme = WindowThemeCache.read(this) ?: ThemeMode.SYSTEM
|
||||
val cachedDynamicColor = WindowThemeCache.readDynamicColor(this) ?: true
|
||||
setContent {
|
||||
val themeMode by settingsPrefs.themeMode.collectAsStateWithLifecycle(cachedTheme)
|
||||
val dynamicColor by settingsPrefs.dynamicColor.collectAsStateWithLifecycle(cachedDynamicColor)
|
||||
val fonts by fontSettings.collectAsStateWithLifecycle(initialValue = null)
|
||||
val context = LocalContext.current
|
||||
// Nothing until the font choice is known, rather than a frame in the default one.
|
||||
val typography = remember(fonts, context) {
|
||||
fonts?.let {
|
||||
calendulaTypography(
|
||||
brand = resolveFontFamily(it.brand, FontRole.BRAND, context),
|
||||
plain = resolveFontFamily(it.plain, FontRole.PLAIN, context),
|
||||
)
|
||||
}
|
||||
} ?: return@setContent
|
||||
CalendulaTheme(
|
||||
darkTheme = themeMode.isDark(isSystemInDarkTheme()),
|
||||
dynamicColor = dynamicColor,
|
||||
pureBlack = themeMode.isPureBlack,
|
||||
typography = typography,
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
if (state is WidgetConfigUiState.Missing) LaunchedEffect(Unit) { finish() }
|
||||
WidgetConfigScreen(
|
||||
state = state,
|
||||
onClose = ::finish,
|
||||
onSave = viewModel::save,
|
||||
onRetry = viewModel::retry,
|
||||
onAppearance = viewModel::setAppearance,
|
||||
onAgendaOptions = viewModel::setAgendaOptions,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun result(appWidgetId: Int) = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
|
||||
|
||||
companion object {
|
||||
/** Opens placed widget [appWidgetId]'s config screen from inside the app. */
|
||||
fun intent(context: Context, appWidgetId: Int): Intent =
|
||||
Intent(context, WidgetConfigActivity::class.java)
|
||||
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package de.jeanlucmakiola.calendula.widget.config
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.settings.pastEventDisplayLabelRes
|
||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetTheme
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetContent
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.metricsFor
|
||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetContent
|
||||
import de.jeanlucmakiola.floret.components.GroupedChoiceRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedHint
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||
import de.jeanlucmakiola.floret.components.GroupedSwitchRow
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.glance.GlanceWidgetPreview
|
||||
import de.jeanlucmakiola.floret.glance.WallpaperWindow
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val PreviewMaxHeight = 280.dp
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun WidgetConfigScreen(
|
||||
state: WidgetConfigUiState,
|
||||
onClose: () -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onAppearance: (WidgetAppearance) -> Unit,
|
||||
onAgendaOptions: (AgendaWidgetOptions) -> Unit,
|
||||
) {
|
||||
val ready = state as? WidgetConfigUiState.Ready
|
||||
val surface = MaterialTheme.colorScheme.surface
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = ready?.let { stringResource(it.kind.label) }.orEmpty(),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.event_edit_close))
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
Button(
|
||||
onClick = onSave,
|
||||
enabled = ready != null && !ready.saving,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.dialog_save))
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = surface),
|
||||
)
|
||||
when (state) {
|
||||
WidgetConfigUiState.Loading, WidgetConfigUiState.Missing -> Box(
|
||||
modifier = Modifier.fillMaxSize().background(surface),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
WidgetConfigUiState.Failed -> Column(
|
||||
modifier = Modifier.fillMaxSize().background(surface).padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.widget_config_load_failed),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
FilledTonalButton(onClick = onRetry) { Text(stringResource(R.string.state_retry)) }
|
||||
}
|
||||
is WidgetConfigUiState.Ready -> {
|
||||
WallpaperWindow(color = surface) {
|
||||
val maxSize = DpSize(maxWidth, PreviewMaxHeight)
|
||||
when (val preview = state.preview) {
|
||||
null -> Box(
|
||||
modifier = Modifier.padding(vertical = 16.dp).height(maxSize.height / 2),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (state.previewFailed) PreviewFailed() else CircularProgressIndicator()
|
||||
}
|
||||
else -> GlanceWidgetPreview(
|
||||
size = state.placedSize,
|
||||
maxSize = maxSize,
|
||||
preview,
|
||||
state.appearance,
|
||||
state.appTheme,
|
||||
modifier = Modifier.padding(vertical = 16.dp),
|
||||
placeholder = { CircularProgressIndicator() },
|
||||
failure = { PreviewFailed() },
|
||||
) {
|
||||
when (preview) {
|
||||
is WidgetPreviewData.Agenda ->
|
||||
AgendaWidgetContent(preview.data, state.appearance, state.appTheme, preview = true)
|
||||
is WidgetPreviewData.Month ->
|
||||
MonthWidgetContent(preview.source, preview.index, state.appearance, state.appTheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ConfigOptions(
|
||||
state = state,
|
||||
onAppearance = onAppearance,
|
||||
onAgendaOptions = onAgendaOptions,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.background(surface),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PreviewFailed() {
|
||||
Text(
|
||||
text = stringResource(R.string.widget_config_preview_failed),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.surface, MaterialTheme.shapes.large)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
internal val WidgetKind.label: Int
|
||||
get() = when (this) {
|
||||
WidgetKind.AGENDA -> R.string.widget_agenda_label
|
||||
WidgetKind.MONTH -> R.string.widget_month_label
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConfigOptions(
|
||||
state: WidgetConfigUiState.Ready,
|
||||
onAppearance: (WidgetAppearance) -> Unit,
|
||||
onAgendaOptions: (AgendaWidgetOptions) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.navigationBarsPadding()
|
||||
.padding(bottom = 16.dp),
|
||||
) {
|
||||
GroupedSectionHeader(stringResource(R.string.settings_section_appearance))
|
||||
GroupedChoiceRow(
|
||||
title = stringResource(R.string.widget_config_background),
|
||||
options = WidgetBackground.entries,
|
||||
selected = state.appearance.background,
|
||||
label = { backgroundLabel(it) },
|
||||
onSelect = { onAppearance(state.appearance.copy(background = it)) },
|
||||
position = Position.Top,
|
||||
)
|
||||
GroupedChoiceRow(
|
||||
title = stringResource(R.string.settings_theme),
|
||||
options = WidgetTheme.entries,
|
||||
selected = state.appearance.theme,
|
||||
label = { themeLabel(it) },
|
||||
onSelect = { onAppearance(state.appearance.copy(theme = it)) },
|
||||
position = Position.Bottom,
|
||||
)
|
||||
state.agenda?.let { options ->
|
||||
AgendaOptions(options = options, state = state, onChange = onAgendaOptions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaOptions(
|
||||
options: AgendaWidgetOptions,
|
||||
state: WidgetConfigUiState.Ready,
|
||||
onChange: (AgendaWidgetOptions) -> Unit,
|
||||
) {
|
||||
var showRange by rememberSaveable { mutableStateOf(false) }
|
||||
var showSize by rememberSaveable { mutableStateOf(false) }
|
||||
var showPast by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
GroupedSectionHeader(stringResource(R.string.widget_config_content))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.widget_config_range),
|
||||
summary = agendaRangeLabel(options.range),
|
||||
position = Position.Top,
|
||||
onClick = { showRange = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.widget_config_text_size),
|
||||
summary = widgetSizeLabel(options.textSize),
|
||||
position = Position.Middle,
|
||||
onClick = { showSize = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_past_events),
|
||||
summary = stringResource(pastEventDisplayLabelRes(options.pastDisplay)),
|
||||
position = Position.Middle,
|
||||
onClick = { showPast = true },
|
||||
)
|
||||
GroupedSwitchRow(
|
||||
title = stringResource(R.string.settings_agenda_show_today),
|
||||
checked = options.showToday,
|
||||
onCheckedChange = { onChange(options.copy(showToday = it)) },
|
||||
position = Position.Bottom,
|
||||
)
|
||||
|
||||
if (showRange) {
|
||||
AgendaRangePicker(
|
||||
title = stringResource(R.string.widget_config_range),
|
||||
description = stringResource(R.string.settings_agenda_widget_range_hint),
|
||||
selected = options.range,
|
||||
weekStart = state.weekStart,
|
||||
onSelect = { onChange(options.copy(range = it)) },
|
||||
onDismiss = { showRange = false },
|
||||
)
|
||||
}
|
||||
if (showSize) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.widget_config_text_size),
|
||||
header = { GroupedHint(stringResource(R.string.settings_widget_size_hint), verticalPadding = 8.dp) },
|
||||
predictiveBack = true,
|
||||
options = WidgetSize.entries,
|
||||
selected = options.textSize,
|
||||
label = { widgetSizeLabel(it) },
|
||||
summary = { widgetSizeSummary(it) },
|
||||
onSelect = { onChange(options.copy(textSize = it)) },
|
||||
onDismiss = { showSize = false },
|
||||
)
|
||||
}
|
||||
if (showPast) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_past_events),
|
||||
header = { GroupedHint(stringResource(R.string.settings_past_events_hint), verticalPadding = 8.dp) },
|
||||
predictiveBack = true,
|
||||
options = PastEventDisplay.entries,
|
||||
selected = options.pastDisplay,
|
||||
label = { stringResource(pastEventDisplayLabelRes(it)) },
|
||||
onSelect = { onChange(options.copy(pastDisplay = it)) },
|
||||
onDismiss = { showPast = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun backgroundLabel(background: WidgetBackground): String = stringResource(
|
||||
when (background) {
|
||||
WidgetBackground.SOLID -> R.string.widget_config_background_solid
|
||||
WidgetBackground.TINTED -> R.string.widget_config_background_tinted
|
||||
WidgetBackground.TRANSPARENT -> R.string.widget_config_background_transparent
|
||||
},
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun themeLabel(theme: WidgetTheme): String = stringResource(
|
||||
when (theme) {
|
||||
WidgetTheme.FOLLOW_APP -> R.string.widget_config_theme_follow_app
|
||||
WidgetTheme.LIGHT -> R.string.settings_theme_light
|
||||
WidgetTheme.DARK -> R.string.settings_theme_dark
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* What a size step does, as a percentage of the smallest one's event text.
|
||||
* Derived from the widget's own [metricsFor] table rather than hardcoded.
|
||||
*/
|
||||
@Composable
|
||||
private fun widgetSizeSummary(size: WidgetSize): String {
|
||||
val base = metricsFor(WidgetSize.SMALL).eventTitle.value
|
||||
val percent = (metricsFor(size).eventTitle.value / base * 100f).roundToInt()
|
||||
return stringResource(R.string.settings_widget_size_summary, percent)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun widgetSizeLabel(size: WidgetSize): String = stringResource(
|
||||
when (size) {
|
||||
WidgetSize.SMALL -> R.string.settings_widget_size_small
|
||||
WidgetSize.MEDIUM -> R.string.settings_widget_size_medium
|
||||
WidgetSize.LARGE -> R.string.settings_widget_size_large
|
||||
WidgetSize.EXTRA_LARGE -> R.string.settings_widget_size_extra_large
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,242 @@
|
||||
package de.jeanlucmakiola.calendula.widget.config
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Context
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions
|
||||
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
|
||||
import de.jeanlucmakiola.calendula.widget.loadThemedMonthSource
|
||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.monthIndexOf
|
||||
import de.jeanlucmakiola.calendula.widget.monthWindowCenter
|
||||
import de.jeanlucmakiola.calendula.widget.agendaWidgetOptions
|
||||
import de.jeanlucmakiola.calendula.widget.putAgendaOptions
|
||||
import de.jeanlucmakiola.calendula.widget.putAppearance
|
||||
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||
import de.jeanlucmakiola.calendula.widget.today
|
||||
import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
||||
import de.jeanlucmakiola.calendula.widget.widgetSettings
|
||||
import de.jeanlucmakiola.calendula.widget.widgetState
|
||||
import de.jeanlucmakiola.floret.glance.updateWithRefresh
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import java.util.Locale
|
||||
|
||||
/** The widgets a config screen can edit, told apart by their receiver. */
|
||||
enum class WidgetKind(val receiver: Class<*>) {
|
||||
AGENDA(AgendaWidgetReceiver::class.java),
|
||||
MONTH(MonthWidgetReceiver::class.java),
|
||||
;
|
||||
|
||||
fun widget(): GlanceAppWidget = when (this) {
|
||||
AGENDA -> AgendaWidget()
|
||||
MONTH -> MonthWidget()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun of(context: Context, appWidgetId: Int): WidgetKind? {
|
||||
val provider = AppWidgetManager.getInstance(context)?.getAppWidgetInfo(appWidgetId)?.provider ?: return null
|
||||
return entries.firstOrNull { it.receiver.name == provider.className }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What the preview draws, loaded with the draft's options. */
|
||||
sealed interface WidgetPreviewData {
|
||||
data class Agenda(val data: AgendaWidgetData) : WidgetPreviewData
|
||||
data class Month(val source: MonthWidgetSource, val index: Int) : WidgetPreviewData
|
||||
}
|
||||
|
||||
sealed interface WidgetConfigUiState {
|
||||
data object Loading : WidgetConfigUiState
|
||||
|
||||
/** The widget is gone, or isn't one of ours; the screen closes. */
|
||||
data object Missing : WidgetConfigUiState
|
||||
|
||||
/** Its stored options couldn't be read. */
|
||||
data object Failed : WidgetConfigUiState
|
||||
|
||||
data class Ready(
|
||||
val kind: WidgetKind,
|
||||
val appearance: WidgetAppearance,
|
||||
/** Null for a widget with no content options. */
|
||||
val agenda: AgendaWidgetOptions?,
|
||||
val appTheme: ThemeMode,
|
||||
val weekStart: DayOfWeek,
|
||||
/** The widget's size on the home screen, which the preview is laid out at. */
|
||||
val placedSize: DpSize,
|
||||
/** Null until the first preview load lands. */
|
||||
val preview: WidgetPreviewData?,
|
||||
/** The first preview load failed, so there's nothing to show. */
|
||||
val previewFailed: Boolean = false,
|
||||
val saving: Boolean = false,
|
||||
) : WidgetConfigUiState
|
||||
}
|
||||
|
||||
/** One-time outcomes of a save, for the screen that's showing when they land. */
|
||||
enum class WidgetConfigEvent { SAVED, SAVE_FAILED }
|
||||
|
||||
/**
|
||||
* Backs [WidgetConfigActivity] (#292): the placed widget's stored options as an
|
||||
* editable draft, a preview loaded with that draft, and the save that writes it
|
||||
* into the widget's Glance state and redraws it.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class WidgetConfigViewModel @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val prefs: SettingsPrefs,
|
||||
savedState: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
|
||||
val appWidgetId: Int = savedState[AppWidgetManager.EXTRA_APPWIDGET_ID] ?: AppWidgetManager.INVALID_APPWIDGET_ID
|
||||
|
||||
private val _state = MutableStateFlow<WidgetConfigUiState>(WidgetConfigUiState.Loading)
|
||||
val state: StateFlow<WidgetConfigUiState> = _state.asStateFlow()
|
||||
|
||||
private val _events = Channel<WidgetConfigEvent>(Channel.BUFFERED)
|
||||
val events: Flow<WidgetConfigEvent> = _events.receiveAsFlow()
|
||||
|
||||
private var previewJob: Job? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch { load() }
|
||||
}
|
||||
|
||||
fun retry() {
|
||||
if (_state.value != WidgetConfigUiState.Failed) return
|
||||
_state.value = WidgetConfigUiState.Loading
|
||||
viewModelScope.launch { load() }
|
||||
}
|
||||
|
||||
private suspend fun load() {
|
||||
val kind = WidgetKind.of(context, appWidgetId)
|
||||
if (kind == null) {
|
||||
_state.value = WidgetConfigUiState.Missing
|
||||
return
|
||||
}
|
||||
_state.value = try {
|
||||
val stored = context.widgetState(GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId))
|
||||
val settings = prefs.widgetSettings.first()
|
||||
WidgetConfigUiState.Ready(
|
||||
kind = kind,
|
||||
appearance = stored.widgetAppearance,
|
||||
agenda = if (kind == WidgetKind.AGENDA) stored.agendaWidgetOptions(settings.agendaDefaults) else null,
|
||||
appTheme = settings.themeMode,
|
||||
weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()),
|
||||
placedSize = placedSize(kind),
|
||||
preview = null,
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: IllegalArgumentException) {
|
||||
// getGlanceIdBy: the widget was removed meanwhile.
|
||||
WidgetConfigUiState.Missing
|
||||
} catch (_: Exception) {
|
||||
WidgetConfigUiState.Failed
|
||||
}
|
||||
reloadPreview()
|
||||
}
|
||||
|
||||
/** The size the launcher placed the widget at (portrait), or a default before it has reported one. */
|
||||
private fun placedSize(kind: WidgetKind): DpSize {
|
||||
val options = AppWidgetManager.getInstance(context)?.getAppWidgetOptions(appWidgetId)
|
||||
val width = options?.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) ?: 0
|
||||
val height = options?.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT) ?: 0
|
||||
if (width > 0 && height > 0) return DpSize(width.dp, height.dp)
|
||||
return when (kind) {
|
||||
WidgetKind.AGENDA -> DpSize(260.dp, 280.dp)
|
||||
WidgetKind.MONTH -> DpSize(320.dp, 340.dp)
|
||||
}
|
||||
}
|
||||
|
||||
fun setAppearance(appearance: WidgetAppearance) = edit { it.copy(appearance = appearance) }
|
||||
|
||||
fun setAgendaOptions(options: AgendaWidgetOptions) {
|
||||
edit { it.copy(agenda = options) }
|
||||
reloadPreview()
|
||||
}
|
||||
|
||||
private fun edit(change: (WidgetConfigUiState.Ready) -> WidgetConfigUiState.Ready) {
|
||||
_state.update { if (it is WidgetConfigUiState.Ready) change(it) else it }
|
||||
}
|
||||
|
||||
private fun reloadPreview() {
|
||||
val ready = _state.value as? WidgetConfigUiState.Ready ?: return
|
||||
previewJob?.cancel()
|
||||
previewJob = viewModelScope.launch {
|
||||
val preview = try {
|
||||
loadPreview(ready)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
// The options stay editable; the preview keeps what it had.
|
||||
edit { it.copy(previewFailed = it.preview == null) }
|
||||
return@launch
|
||||
}
|
||||
edit { it.copy(preview = preview, previewFailed = false) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadPreview(ready: WidgetConfigUiState.Ready): WidgetPreviewData = when (ready.kind) {
|
||||
WidgetKind.AGENDA -> {
|
||||
val settings = prefs.widgetSettings.first()
|
||||
WidgetPreviewData.Agenda(context.loadAgendaWidgetData(settings, requireNotNull(ready.agenda)))
|
||||
}
|
||||
WidgetKind.MONTH -> {
|
||||
val index = monthIndexOf(today(systemZone()))
|
||||
WidgetPreviewData.Month(context.loadThemedMonthSource(monthWindowCenter(index)).content, index)
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes the draft into the widget and redraws it, then reports how that went on [events]. */
|
||||
fun save() {
|
||||
val ready = _state.value as? WidgetConfigUiState.Ready ?: return
|
||||
if (ready.saving) return
|
||||
edit { it.copy(saving = true) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId)
|
||||
ready.kind.widget().updateWithRefresh(context, glanceId) { state ->
|
||||
state.putAppearance(ready.appearance)
|
||||
ready.agenda?.let { state.putAgendaOptions(it) }
|
||||
}
|
||||
_events.send(WidgetConfigEvent.SAVED)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: IllegalArgumentException) {
|
||||
_state.value = WidgetConfigUiState.Missing
|
||||
} catch (_: Exception) {
|
||||
edit { it.copy(saving = false) }
|
||||
_events.send(WidgetConfigEvent.SAVE_FAILED)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,11 @@
|
||||
package de.jeanlucmakiola.calendula.widget.month
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.month.DaySeating
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
|
||||
import de.jeanlucmakiola.calendula.ui.month.seating
|
||||
import de.jeanlucmakiola.calendula.ui.month.spanAt
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/** Event rows (lanes) a week draws at most before the rest collapse into "+N". */
|
||||
internal const val MAX_LANES = 3
|
||||
|
||||
/**
|
||||
* View ids one rendering may spend. Glance has 499 and fails the whole widget
|
||||
* past them (#372); staying well under also keeps a busy month's payload, sent
|
||||
* once per host size, clear of the binder limit #214 ran into.
|
||||
*/
|
||||
internal const val VIEW_BUDGET = 400
|
||||
|
||||
/**
|
||||
* What views cost in Glance 1.2's view ids. A clickable view that isn't a
|
||||
* Button is wrapped in a Box sized like it, with a ripple Image and the view
|
||||
* itself filling it. Below Android 12 a view with a fixed width or height is
|
||||
* inflated into a sizing frame first, which takes a second id.
|
||||
*/
|
||||
internal class ViewIds(sdkInt: Int = Build.VERSION.SDK_INT) {
|
||||
val plain = 1
|
||||
val sized = if (sdkInt < Build.VERSION_CODES.S) 2 else 1
|
||||
val clickablePlain = plain + 2
|
||||
val clickableSized = sized + 2
|
||||
|
||||
/**
|
||||
* `MonthWidgetBody`'s views outside the week rows: its Column, the header
|
||||
* row with three sized clickable icon boxes, their sized icons and the
|
||||
* clickable title, the spacer, and the weekday row of sized labels.
|
||||
*/
|
||||
val fixed = plain + plain + 3 * (clickableSized + sized) + clickablePlain + sized + plain + 7 * sized
|
||||
}
|
||||
|
||||
/** A cell [cols] columns wide holding [item], or a merged gap where [item] is null. */
|
||||
internal data class GridCell<T : Any>(val item: T?, val cols: Int)
|
||||
@@ -67,8 +36,11 @@ internal fun <T : Any> mergeGaps(columns: Int, cellAt: (Int) -> GridCell<T>?): L
|
||||
return cells
|
||||
}
|
||||
|
||||
/** One week of the grid seated with [laneCap] lanes, computed once for both drawing and counting. */
|
||||
internal class WeekLayout(val week: MonthWeek, val laneCap: Int) {
|
||||
/**
|
||||
* One week of the grid seated with [laneCap] lanes. [showsOverflow] is off when
|
||||
* the week has no room for even the "+N" row.
|
||||
*/
|
||||
internal class WeekLayout(val week: MonthWeek, val laneCap: Int, val showsOverflow: Boolean = true) {
|
||||
private val seating: List<DaySeating> = week.days.indices.map { week.seating(it, laneCap) }
|
||||
|
||||
/** Lanes filled from the top; empty lanes below are not drawn. */
|
||||
@@ -88,57 +60,29 @@ internal class WeekLayout(val week: MonthWeek, val laneCap: Int) {
|
||||
if (hidden.none { it > 0 }) emptyList()
|
||||
else mergeGaps(hidden.size) { col -> hidden[col].takeIf { it > 0 }?.let { GridCell(it, 1) } }
|
||||
}
|
||||
|
||||
/**
|
||||
* The view ids `WeekRow` spends on this week, counted the way it draws it:
|
||||
* the week Box, the strip Row and its seven sized clickable strips, the
|
||||
* content Column, the sized day-number row and its sized numbers (today's
|
||||
* pill adds a sized Box around a plain Text), the sized spacer, each lane's
|
||||
* Row and sized spacer with sized gaps and bars (a sized Box around a
|
||||
* filling Box and Text), and the "+N" Row of sized cells.
|
||||
*/
|
||||
fun viewIds(today: LocalDate, ids: ViewIds): Int {
|
||||
val days = week.days.size
|
||||
val strips = ids.plain + days * ids.clickableSized
|
||||
val numbers = ids.sized + days * ids.sized + if (today in week.days) ids.sized + ids.plain else 0
|
||||
val lanes = lanes.sumOf { cells ->
|
||||
ids.plain + ids.sized + cells.sumOf { if (it.item == null) ids.sized else ids.sized + 2 * ids.plain }
|
||||
}
|
||||
val overflow = if (overflow.isEmpty()) 0 else ids.plain + overflow.size * ids.sized
|
||||
return ids.plain + strips + ids.plain + numbers + ids.sized + lanes + overflow
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seats each week so the whole grid fits [budget] view ids (#372). Each round
|
||||
* folds the lowest lanes of whichever week saves the most ids per lane given
|
||||
* up, their events joining "+N", until the month fits.
|
||||
* 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 layoutWeeks(
|
||||
weeks: List<MonthWeek>,
|
||||
today: LocalDate,
|
||||
ids: ViewIds = ViewIds(),
|
||||
budget: Int = VIEW_BUDGET - ids.fixed,
|
||||
): List<WeekLayout> {
|
||||
val layouts = weeks.map { week -> WeekLayout(week, WeekLayout(week, MAX_LANES).usedLanes) }.toMutableList()
|
||||
val costs = layouts.map { it.viewIds(today, ids) }.toMutableList()
|
||||
while (costs.sum() > budget) {
|
||||
var best: Triple<Int, WeekLayout, Int>? = null
|
||||
var bestRate = 0.0
|
||||
layouts.forEachIndexed { i, layout ->
|
||||
for (cap in layout.laneCap - 1 downTo 0) {
|
||||
val folded = WeekLayout(layout.week, cap)
|
||||
val cost = folded.viewIds(today, ids)
|
||||
val rate = (costs[i] - cost).toDouble() / (layout.laneCap - cap)
|
||||
if (rate > bestRate) {
|
||||
bestRate = rate
|
||||
best = Triple(i, folded, cost)
|
||||
}
|
||||
}
|
||||
}
|
||||
val (i, folded, cost) = best ?: break
|
||||
layouts[i] = folded
|
||||
costs[i] = cost
|
||||
}
|
||||
return layouts
|
||||
internal fun lanesFitting(weekHeight: Dp): Int =
|
||||
((weekHeight.value - DAY_ROW_DP - LANES_TOP_DP) / LANE_ROW_DP).toInt().coerceIn(0, MAX_LANES)
|
||||
|
||||
/**
|
||||
* Seats each week in [lanes] lanes. A week whose events need more gives its
|
||||
* last lane to the "+N" row instead.
|
||||
*/
|
||||
internal fun fitWeeks(weeks: List<MonthWeek>, lanes: Int): List<WeekLayout> = weeks.map { week ->
|
||||
if (lanes == 0) return@map WeekLayout(week, laneCap = 0, showsOverflow = false)
|
||||
val needed = WeekLayout(week, UNCAPPED_LANES).usedLanes
|
||||
WeekLayout(week, if (needed <= lanes) lanes else lanes - 1)
|
||||
}
|
||||
|
||||
/** Lanes a week shows at most, however tall the widget. */
|
||||
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
|
||||
private const val UNCAPPED_LANES = 64
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package de.jeanlucmakiola.calendula.widget.month
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.graphics.Typeface
|
||||
import android.text.SpannableString
|
||||
import android.text.Spanned
|
||||
import android.text.style.StrikethroughSpan
|
||||
import android.text.style.StyleSpan
|
||||
import android.view.View
|
||||
import android.widget.RemoteViews
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||
import de.jeanlucmakiola.floret.glance.RemoteColor
|
||||
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
|
||||
import de.jeanlucmakiola.floret.glance.remoteColor
|
||||
import de.jeanlucmakiola.floret.glance.setImageTint
|
||||
import de.jeanlucmakiola.floret.glance.setTextColor
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.Month
|
||||
|
||||
/** The theme colours the plain-RemoteViews grid draws with. */
|
||||
internal data class GridColors(
|
||||
val onSurface: RemoteColor,
|
||||
val onSurfaceVariant: RemoteColor,
|
||||
val primary: RemoteColor,
|
||||
val onPrimary: RemoteColor,
|
||||
val cell: RemoteColor,
|
||||
)
|
||||
|
||||
/**
|
||||
* The month's week rows as one plain RemoteViews tree, embedded in the Glance
|
||||
* widget so its views don't draw on Glance's view-id pool (#372): every event
|
||||
* gets its bar, and only what can't fit the widget's height folds into "+N".
|
||||
*
|
||||
* A host may reapply a new rendering onto the views of the last one rather than
|
||||
* inflate it afresh, so every container is cleared before it's filled and every
|
||||
* view state is set both ways, never just when it differs from the layout.
|
||||
*/
|
||||
internal fun monthGridViews(
|
||||
context: Context,
|
||||
layouts: List<WeekLayout>,
|
||||
currentMonth: Month,
|
||||
today: LocalDate,
|
||||
soften: Boolean,
|
||||
colors: GridColors,
|
||||
mode: WidgetThemeMode,
|
||||
): RemoteViews {
|
||||
val grid = RemoteViews(context.packageName, R.layout.widget_month_grid)
|
||||
grid.removeAllViews(R.id.month_grid)
|
||||
layouts.forEach { grid.addView(R.id.month_grid, weekViews(context, it, currentMonth, today, soften, colors, mode)) }
|
||||
return grid
|
||||
}
|
||||
|
||||
private fun weekViews(
|
||||
context: Context,
|
||||
layout: WeekLayout,
|
||||
currentMonth: Month,
|
||||
today: LocalDate,
|
||||
soften: Boolean,
|
||||
colors: GridColors,
|
||||
mode: WidgetThemeMode,
|
||||
): RemoteViews {
|
||||
val week = RemoteViews(context.packageName, R.layout.widget_month_week)
|
||||
week.removeAllViews(R.id.week_lanes)
|
||||
layout.week.days.forEachIndexed { col, date ->
|
||||
week.setOnClickPendingIntent(STRIP_IDS[col], openDayIntent(context, date))
|
||||
// Each day its own rounded cell, as in the app's month grid. Days around
|
||||
// the month get a shade over their (opaque) cell, so they recede without
|
||||
// letting a wallpaper show through.
|
||||
week.setImageTint(context, CELL_IDS[col], colors.cell)
|
||||
val outside = date.month != currentMonth
|
||||
week.setViewVisibility(CELL_DIM_IDS[col], if (outside) View.VISIBLE else View.GONE)
|
||||
if (outside) {
|
||||
week.setImageTint(context, CELL_DIM_IDS[col], mode.remoteColor(OUTSIDE_SHADE_DAY, OUTSIDE_SHADE_NIGHT))
|
||||
}
|
||||
val dayId = DAY_IDS[col]
|
||||
val isToday = date == today
|
||||
week.setTextViewText(dayId, if (isToday) bold(date.day.toString()) else date.day.toString())
|
||||
week.setTextColor(context, dayId, when {
|
||||
isToday -> colors.onPrimary
|
||||
date.month == currentMonth -> colors.onSurface
|
||||
else -> colors.onSurfaceVariant
|
||||
})
|
||||
week.setViewVisibility(TODAY_IDS[col], if (isToday) View.VISIBLE else View.GONE)
|
||||
if (isToday) week.setImageTint(context, TODAY_IDS[col], colors.primary)
|
||||
}
|
||||
layout.lanes.forEach { cells ->
|
||||
val lane = laneViews(context)
|
||||
cells.forEach { cell ->
|
||||
val event = cell.item
|
||||
lane.addView(R.id.lane, if (event == null) gap(context, cell.cols) else bar(context, event, cell.cols, soften, mode))
|
||||
}
|
||||
week.addView(R.id.week_lanes, lane)
|
||||
}
|
||||
if (layout.showsOverflow && layout.overflow.isNotEmpty()) {
|
||||
val lane = laneViews(context)
|
||||
layout.overflow.forEach { cell ->
|
||||
val count = cell.item
|
||||
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)
|
||||
}
|
||||
}
|
||||
week.addView(R.id.week_lanes, lane)
|
||||
}
|
||||
return week
|
||||
}
|
||||
|
||||
private fun laneViews(context: Context) =
|
||||
RemoteViews(context.packageName, R.layout.widget_month_lane).apply { removeAllViews(R.id.lane) }
|
||||
|
||||
private fun gap(context: Context, cols: Int) = RemoteViews(context.packageName, GAP_LAYOUTS[cols - 1])
|
||||
|
||||
private fun bar(context: Context, event: EventInstance, cols: Int, soften: Boolean, mode: WidgetThemeMode): RemoteViews {
|
||||
val views = RemoteViews(context.packageName, BAR_LAYOUTS[cols - 1])
|
||||
val dayFill = eventFill(event.color, dark = false, soften)
|
||||
val nightFill = eventFill(event.color, dark = true, soften)
|
||||
views.setImageTint(context, R.id.bar_bg, mode.remoteColor(dayFill, nightFill))
|
||||
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||
// A span, like Glance's own decoration: paint flags would drop the text's defaults.
|
||||
views.setTextViewText(R.id.bar_title, if (event.isDeclined) span(title, StrikethroughSpan()) else title)
|
||||
views.setTextColor(context, R.id.bar_title, mode.remoteColor(eventInk(dayFill), eventInk(nightFill)))
|
||||
return views
|
||||
}
|
||||
|
||||
private fun bold(text: String): CharSequence = span(text, StyleSpan(Typeface.BOLD))
|
||||
|
||||
private fun span(text: String, span: Any): CharSequence =
|
||||
SpannableString(text).apply { setSpan(span, 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) }
|
||||
|
||||
private fun openDayIntent(context: Context, date: LocalDate): PendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
date.toEpochDays().toInt(),
|
||||
MainActivity.openDateIntent(context, date, CalendarView.Month),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
private val STRIP_IDS = intArrayOf(R.id.strip_0, R.id.strip_1, R.id.strip_2, R.id.strip_3, R.id.strip_4, R.id.strip_5, R.id.strip_6)
|
||||
/** The shade over a day outside the month: a dark cell needs more of it to read as dimmed. */
|
||||
private val OUTSIDE_SHADE_DAY = Color.Black.copy(alpha = 0.18f)
|
||||
private val OUTSIDE_SHADE_NIGHT = Color.Black.copy(alpha = 0.50f)
|
||||
|
||||
private val CELL_DIM_IDS = intArrayOf(
|
||||
R.id.cell_dim_0, R.id.cell_dim_1, R.id.cell_dim_2, R.id.cell_dim_3, R.id.cell_dim_4, R.id.cell_dim_5, R.id.cell_dim_6,
|
||||
)
|
||||
private val CELL_IDS = intArrayOf(R.id.cell_0, R.id.cell_1, R.id.cell_2, R.id.cell_3, R.id.cell_4, R.id.cell_5, R.id.cell_6)
|
||||
private val DAY_IDS = intArrayOf(R.id.day_0, R.id.day_1, R.id.day_2, R.id.day_3, R.id.day_4, R.id.day_5, R.id.day_6)
|
||||
private val TODAY_IDS = intArrayOf(
|
||||
R.id.today_bg_0, R.id.today_bg_1, R.id.today_bg_2, R.id.today_bg_3, R.id.today_bg_4, R.id.today_bg_5, R.id.today_bg_6,
|
||||
)
|
||||
private val BAR_LAYOUTS = intArrayOf(
|
||||
R.layout.widget_month_bar_1, R.layout.widget_month_bar_2, R.layout.widget_month_bar_3, R.layout.widget_month_bar_4,
|
||||
R.layout.widget_month_bar_5, R.layout.widget_month_bar_6, R.layout.widget_month_bar_7,
|
||||
)
|
||||
private val GAP_LAYOUTS = intArrayOf(
|
||||
R.layout.widget_month_gap_1, R.layout.widget_month_gap_2, R.layout.widget_month_gap_3, R.layout.widget_month_gap_4,
|
||||
R.layout.widget_month_gap_5, R.layout.widget_month_gap_6, R.layout.widget_month_gap_7,
|
||||
)
|
||||
@@ -1,7 +1,5 @@
|
||||
package de.jeanlucmakiola.calendula.widget.month
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
@@ -13,35 +11,33 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.glance.ColorFilter
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.Image
|
||||
import androidx.glance.ImageProvider
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.LocalSize
|
||||
import androidx.glance.action.ActionParameters
|
||||
import androidx.glance.action.actionParametersOf
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.AndroidRemoteViews
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
|
||||
import androidx.glance.background
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.appwidget.SizeMode
|
||||
import androidx.glance.color.ColorProvider
|
||||
import androidx.glance.appwidget.action.ActionCallback
|
||||
import androidx.glance.appwidget.action.actionRunCallback
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.appwidget.state.getAppWidgetState
|
||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||
import androidx.glance.background
|
||||
import androidx.glance.currentState
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.Spacer
|
||||
import androidx.glance.layout.fillMaxHeight
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.height
|
||||
@@ -49,23 +45,29 @@ import androidx.glance.layout.padding
|
||||
import androidx.glance.layout.size
|
||||
import androidx.glance.layout.width
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import androidx.glance.text.FontWeight
|
||||
import de.jeanlucmakiola.floret.glance.LocalWidgetThemeMode
|
||||
import de.jeanlucmakiola.floret.glance.WidgetColorRole
|
||||
import de.jeanlucmakiola.floret.glance.remoteColor
|
||||
import de.jeanlucmakiola.floret.glance.WidgetActionStyle
|
||||
import de.jeanlucmakiola.floret.glance.WidgetHeader
|
||||
import de.jeanlucmakiola.floret.glance.WidgetHeaderAction
|
||||
import de.jeanlucmakiola.floret.glance.WidgetHeaderActionGap
|
||||
import de.jeanlucmakiola.floret.glance.WidgetHeaderTextAction
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextAlign
|
||||
import androidx.glance.text.TextStyle
|
||||
import androidx.glance.unit.ColorProvider
|
||||
import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
|
||||
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
||||
import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
||||
import de.jeanlucmakiola.floret.glance.wallpaperBacking
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
|
||||
import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource
|
||||
import de.jeanlucmakiola.calendula.widget.loadCachedMonthSource
|
||||
import de.jeanlucmakiola.calendula.widget.monthWindowCenter
|
||||
import de.jeanlucmakiola.floret.glance.WidgetData
|
||||
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
|
||||
@@ -86,10 +88,16 @@ import java.util.Locale
|
||||
/** Per-widget state: the displayed month as `year * 12 + monthOrdinal`. */
|
||||
private val MONTH_INDEX_KEY = intPreferencesKey("month_index")
|
||||
|
||||
private val LANE_HEIGHT = 14.dp
|
||||
private val DAY_NUMBER_HEIGHT = 18.dp
|
||||
private val GRID_HPADDING = 8.dp
|
||||
|
||||
/**
|
||||
* Height around the week rows: body padding, the header's bottom padding and
|
||||
* spacer and the weekday pills' padding, then the header (its 28dp pills, or a title line in its transparent
|
||||
* pill when that's taller) and the weekday row, the text lines at [fontScale].
|
||||
*/
|
||||
private fun gridChromeHeight(fontScale: Float): Dp =
|
||||
21.dp + maxOf(28.dp, 19.dp * fontScale + 8.dp) + 15.dp * fontScale
|
||||
|
||||
private fun currentMonthIndex(zone: TimeZone): Int = monthIndexOf(today(zone))
|
||||
|
||||
private fun yearMonthOf(index: Int): YearMonth =
|
||||
@@ -100,9 +108,9 @@ private fun yearMonthOf(index: Int): YearMonth =
|
||||
* event bars and titled single-day pills (the in-app lane layout via
|
||||
* [layoutMonthWeeks]), and prev/next/today navigation.
|
||||
*
|
||||
* Columns are sized explicitly from [LocalSize] (hence [SizeMode.Exact]) so a
|
||||
* multi-day span renders as a single Box spanning its columns — connected, no
|
||||
* inter-cell seam, with rounded end caps. The displayed month lives in Glance
|
||||
* The week rows are one plain RemoteViews tree ([monthGridViews]) embedded in
|
||||
* the Glance widget, so a busy month isn't held to Glance's view-id pool (#372);
|
||||
* the lanes each week shows come from the widget's height. The displayed month lives in Glance
|
||||
* state and is read reactively in the composition ([currentState]) so the arrows
|
||||
* move it via plain recomposition, not a (here-unreliable) widget session reload.
|
||||
*
|
||||
@@ -113,7 +121,7 @@ private fun yearMonthOf(index: Int): YearMonth =
|
||||
* the launcher rebinds. [SizeMode.Exact] serialises the grid once per size the
|
||||
* host reports, so the ceiling arrives at half the view count you would expect: a
|
||||
* grid that emitted a view per cell blew past it after three taps (#214). Every
|
||||
* row here emits only what it fills.
|
||||
* row here emits only what it fills, and lanes stop at what the height shows.
|
||||
*/
|
||||
class MonthWidget : GlanceAppWidget() {
|
||||
|
||||
@@ -123,8 +131,8 @@ class MonthWidget : GlanceAppWidget() {
|
||||
* Exact rather than [SizeMode.Single] or [SizeMode.Responsive], both of which
|
||||
* return early from `GlanceAppWidget.resize` — under those the grid is never
|
||||
* recomposed for a new size and a resized widget clips instead of reflowing.
|
||||
* Exact costs one serialised copy per host size, which is why a rendering is
|
||||
* held to [VIEW_BUDGET] view ids, folding lanes into "+N" on a busy month.
|
||||
* Exact costs one serialised copy per host size, so the grid draws only the
|
||||
* lanes the size can show, folding the rest into "+N".
|
||||
*/
|
||||
override val sizeMode = SizeMode.Exact
|
||||
|
||||
@@ -134,16 +142,17 @@ class MonthWidget : GlanceAppWidget() {
|
||||
val stamp = refreshStamp(context, id)
|
||||
val initialCenter = monthWindowCenter(shown)
|
||||
// Shared by every size Exact composes, so each loads nothing of its own.
|
||||
val widgetData = WidgetData(context.loadMonthWidgetSource(id, initialCenter, stamp), initialCenter, stamp)
|
||||
val widgetData = WidgetData(context.loadCachedMonthSource(id, initialCenter, stamp), initialCenter, stamp)
|
||||
provideContent {
|
||||
val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(systemZone())
|
||||
// Reloads when paging leaves the loaded window, and on every redraw,
|
||||
// which a running session would otherwise never see (#420).
|
||||
val center = monthWindowCenter(index)
|
||||
val currentStamp = currentRefreshStamp()
|
||||
val source = widgetData.current(center, currentStamp) {
|
||||
context.loadMonthWidgetSource(id, center, currentStamp)
|
||||
val loaded = widgetData.current(center, currentStamp) {
|
||||
context.loadCachedMonthSource(id, center, currentStamp)
|
||||
}
|
||||
val source = loaded.content
|
||||
// A jump out of the loaded window keeps the last month it covers
|
||||
// until the reload lands, rather than drawing an empty grid.
|
||||
var lastShown by remember { mutableIntStateOf(index) }
|
||||
@@ -153,13 +162,24 @@ class MonthWidget : GlanceAppWidget() {
|
||||
else -> index.coerceIn(source.months)
|
||||
}
|
||||
SideEffect { lastShown = shown }
|
||||
CalendulaGlanceTheme {
|
||||
MonthWidgetBody(source = source, index = shown)
|
||||
}
|
||||
MonthWidgetContent(
|
||||
source = source,
|
||||
index = shown,
|
||||
appearance = currentState<Preferences>().widgetAppearance,
|
||||
appTheme = loaded.appTheme,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The whole month widget showing month [index], shared with its config screen's preview (#292). */
|
||||
@Composable
|
||||
internal fun MonthWidgetContent(source: MonthWidgetSource, index: Int, appearance: WidgetAppearance, appTheme: ThemeMode) {
|
||||
CalendulaWidgetRoot(appearance, appTheme) {
|
||||
MonthWidgetBody(source = source, index = index)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step the displayed month by the `delta` action parameter (±1).
|
||||
*
|
||||
@@ -209,12 +229,11 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) {
|
||||
Column(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.background(GlanceTheme.colors.surface)
|
||||
.padding(horizontal = GRID_HPADDING, vertical = 6.dp),
|
||||
) {
|
||||
when (source) {
|
||||
MonthWidgetSource.NeedsPermission -> {
|
||||
MonthHeader(label = "Calendula")
|
||||
MonthHeader(label = "Calendula", today = today(systemZone()))
|
||||
PermissionMessage()
|
||||
}
|
||||
is MonthWidgetSource.Ready -> {
|
||||
@@ -223,18 +242,29 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) {
|
||||
// Column width from the live widget size, minus our H padding.
|
||||
val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
|
||||
val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
|
||||
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 mode = LocalWidgetThemeMode.current
|
||||
val colors = GridColors(
|
||||
onSurface = remoteColor(WidgetColorRole.ON_SURFACE),
|
||||
onSurfaceVariant = remoteColor(WidgetColorRole.ON_SURFACE_VARIANT),
|
||||
primary = remoteColor(WidgetColorRole.PRIMARY),
|
||||
onPrimary = remoteColor(WidgetColorRole.ON_PRIMARY),
|
||||
cell = remoteColor(monthCellRole()),
|
||||
)
|
||||
|
||||
MonthHeader(label = monthLabel(ym, source.today.year))
|
||||
Spacer(GlanceModifier.height(2.dp))
|
||||
WeekdayHeader(weekStart = source.weekStart, colW = colW)
|
||||
layoutWeeks(weeks, source.today).forEach { layout ->
|
||||
WeekRow(
|
||||
layout = layout,
|
||||
currentMonth = ym.month,
|
||||
today = source.today,
|
||||
soften = source.soften,
|
||||
colW = colW,
|
||||
modifier = GlanceModifier.defaultWeight(),
|
||||
MonthHeader(label = monthLabel(ym, source.today.year), today = source.today)
|
||||
Spacer(GlanceModifier.height(4.dp))
|
||||
// No backing behind the grid: the weekday pills and day cells
|
||||
// each carry their own fill, so a transparent widget stays
|
||||
// readable without one blob over the wallpaper (#292).
|
||||
Column(modifier = GlanceModifier.fillMaxWidth().defaultWeight()) {
|
||||
WeekdayHeader(weekStart = source.weekStart, colW = colW)
|
||||
AndroidRemoteViews(
|
||||
remoteViews = monthGridViews(context, layouts, ym.month, source.today, source.soften, colors, mode),
|
||||
modifier = GlanceModifier.fillMaxWidth().defaultWeight(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -243,89 +273,79 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MonthHeader(label: String) {
|
||||
private fun MonthHeader(label: String, today: LocalDate) {
|
||||
val context = LocalContext.current
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
// Tap the month/year title to open the app on the month view (issue #18),
|
||||
// rooted over the default home so backing out returns there.
|
||||
WidgetHeader(
|
||||
title = label,
|
||||
titleAction = actionStartActivity(MainActivity.openViewIntent(context, CalendarView.Month)),
|
||||
modifier = GlanceModifier.padding(bottom = 2.dp),
|
||||
titleInset = 4.dp,
|
||||
) {
|
||||
HeaderIcon(
|
||||
resId = R.drawable.ic_widget_chevron_left,
|
||||
WidgetHeaderAction(
|
||||
icon = ImageProvider(R.drawable.ic_widget_chevron_left),
|
||||
contentDescription = context.getString(R.string.widget_prev_month),
|
||||
onClick = GlanceModifier.clickable(
|
||||
actionRunCallback<ShiftMonthAction>(
|
||||
actionParametersOf(ShiftMonthAction.deltaKey to -1),
|
||||
),
|
||||
),
|
||||
action = actionRunCallback<ShiftMonthAction>(actionParametersOf(ShiftMonthAction.deltaKey to -1)),
|
||||
style = WidgetActionStyle.TONAL,
|
||||
)
|
||||
// Tap the month/year title to open the app on the month view (issue #18),
|
||||
// rooted over the default home so backing out returns there.
|
||||
Text(
|
||||
text = label,
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.primary,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
modifier = GlanceModifier
|
||||
.defaultWeight()
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openViewIntent(context, CalendarView.Month),
|
||||
),
|
||||
),
|
||||
)
|
||||
// The "today" button snaps the grid back to the current month in place.
|
||||
HeaderIcon(
|
||||
resId = R.drawable.ic_widget_today,
|
||||
WidgetHeaderActionGap()
|
||||
// The "today" pill carries today's number like the app icon, and snaps
|
||||
// the grid back to the current month in place.
|
||||
WidgetHeaderTextAction(
|
||||
text = today.day.toString(),
|
||||
contentDescription = context.getString(R.string.widget_today),
|
||||
onClick = GlanceModifier.clickable(actionRunCallback<ResetMonthAction>()),
|
||||
action = actionRunCallback<ResetMonthAction>(),
|
||||
style = WidgetActionStyle.TONAL,
|
||||
)
|
||||
HeaderIcon(
|
||||
resId = R.drawable.ic_widget_chevron_right,
|
||||
WidgetHeaderActionGap()
|
||||
WidgetHeaderAction(
|
||||
icon = ImageProvider(R.drawable.ic_widget_chevron_right),
|
||||
contentDescription = context.getString(R.string.widget_next_month),
|
||||
onClick = GlanceModifier.clickable(
|
||||
actionRunCallback<ShiftMonthAction>(
|
||||
actionParametersOf(ShiftMonthAction.deltaKey to 1),
|
||||
),
|
||||
),
|
||||
action = actionRunCallback<ShiftMonthAction>(actionParametersOf(ShiftMonthAction.deltaKey to 1)),
|
||||
style = WidgetActionStyle.TONAL,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeaderIcon(resId: Int, contentDescription: String, onClick: GlanceModifier) {
|
||||
Box(
|
||||
modifier = GlanceModifier.size(40.dp).then(onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
provider = ImageProvider(resId),
|
||||
contentDescription = contentDescription,
|
||||
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
|
||||
modifier = GlanceModifier.size(20.dp),
|
||||
WidgetHeaderActionGap()
|
||||
WidgetHeaderAction(
|
||||
icon = ImageProvider(R.drawable.ic_widget_add),
|
||||
contentDescription = context.getString(R.string.widget_new_event),
|
||||
action = actionStartActivity(MainActivity.openCreateIntent(context, today)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekdayHeader(weekStart: DayOfWeek, colW: Dp) {
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
Row(modifier = GlanceModifier.fillMaxWidth().padding(bottom = 1.dp)) {
|
||||
weekdayNarrowNames(weekStart).forEach { name ->
|
||||
Text(
|
||||
text = name,
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.onSurfaceVariant,
|
||||
fontSize = 11.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
modifier = GlanceModifier.width(colW),
|
||||
)
|
||||
Box(modifier = GlanceModifier.width(colW).padding(horizontal = 1.dp)) {
|
||||
Text(
|
||||
text = name,
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.onPrimaryContainer,
|
||||
fontSize = 11.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.cornerRadius(8.dp)
|
||||
.background(GlanceTheme.colors.primaryContainer)
|
||||
.padding(vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The fill of a day cell: one tone off a filled root, the widget surface over a wallpaper. */
|
||||
private fun monthCellRole(background: WidgetBackground): WidgetColorRole = when (background) {
|
||||
WidgetBackground.SOLID -> WidgetColorRole.SURFACE_VARIANT
|
||||
WidgetBackground.TINTED, WidgetBackground.TRANSPARENT -> WidgetColorRole.WIDGET_BACKGROUND
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun monthCellRole(): WidgetColorRole = monthCellRole(LocalWidgetBackground.current)
|
||||
|
||||
/** Narrow weekday initials starting at [weekStart], in the device locale.
|
||||
* Computed outside the composable so the locale read stays observable-safe. */
|
||||
private fun weekdayNarrowNames(weekStart: DayOfWeek): List<String> {
|
||||
@@ -336,176 +356,6 @@ private fun weekdayNarrowNames(weekStart: DayOfWeek): List<String> {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekRow(
|
||||
layout: WeekLayout,
|
||||
currentMonth: Month,
|
||||
today: LocalDate,
|
||||
soften: Boolean,
|
||||
colW: Dp,
|
||||
modifier: GlanceModifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val week = layout.week
|
||||
Box(modifier = modifier.fillMaxWidth()) {
|
||||
// Day-open targets, one full-height strip per column, sitting *under* the
|
||||
// content: a tap anywhere in a day still opens it, as in the app, without
|
||||
// every cell in the column carrying a click target of its own. The content
|
||||
// above is not clickable, event bars included, so touches fall through to
|
||||
// this layer and a day opens wherever it is tapped (#386).
|
||||
Row(modifier = GlanceModifier.fillMaxSize()) {
|
||||
week.days.forEach { date ->
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.width(colW)
|
||||
.fillMaxHeight()
|
||||
.clickable(openDayAction(context, date)),
|
||||
) {}
|
||||
}
|
||||
}
|
||||
Column(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
// Day numbers. Fixed height so every week's number row lines up whether
|
||||
// or not it holds today's larger pill.
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth().height(DAY_NUMBER_HEIGHT),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
week.days.forEach { date ->
|
||||
DayNumber(
|
||||
date = date,
|
||||
isToday = date == today,
|
||||
inMonth = date.month == currentMonth,
|
||||
colW = colW,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(GlanceModifier.height(2.dp))
|
||||
// One lane row per event row. A multi-day span is a single Box spanning
|
||||
// its columns (colW * n) so it's connected with no seam and rounded ends.
|
||||
// Lanes past the last one this week fills emit nothing at all.
|
||||
// The grid's view budget caps the lanes per week (#372); what doesn't
|
||||
// fit folds into the "+N" row. Counted the same way by WeekLayout.viewIds.
|
||||
layout.lanes.forEach { cells ->
|
||||
LaneRow(cells = cells, soften = soften, colW = colW)
|
||||
Spacer(GlanceModifier.height(1.dp))
|
||||
}
|
||||
if (layout.overflow.isNotEmpty()) OverflowRow(cells = layout.overflow, colW = colW)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open [date]'s day view rooted in the month view (so back returns to the month
|
||||
* grid) — the same target the in-app month grid uses when a day cell is tapped.
|
||||
* The only tap target in a day column, so a tap anywhere on a day opens it.
|
||||
*/
|
||||
private fun openDayAction(context: Context, date: LocalDate) =
|
||||
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month))
|
||||
|
||||
/**
|
||||
* A day's number. Every day but today is a bare centred [Text] carrying the column
|
||||
* width itself — a wrapping Box costs three views, and there are 42 of these.
|
||||
* Today keeps its filled circle, which does need the Box.
|
||||
*/
|
||||
@Composable
|
||||
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
|
||||
val style = TextStyle(
|
||||
color = when {
|
||||
isToday -> GlanceTheme.colors.onPrimary
|
||||
inMonth -> GlanceTheme.colors.onSurface
|
||||
else -> GlanceTheme.colors.onSurfaceVariant
|
||||
},
|
||||
fontSize = 11.sp,
|
||||
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
if (!isToday) {
|
||||
Text(text = date.day.toString(), style = style, modifier = GlanceModifier.width(colW))
|
||||
return
|
||||
}
|
||||
Box(modifier = GlanceModifier.width(colW), contentAlignment = Alignment.Center) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.size(DAY_NUMBER_HEIGHT)
|
||||
.cornerRadius(DAY_NUMBER_HEIGHT / 2)
|
||||
.background(GlanceTheme.colors.primary),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(text = date.day.toString(), style = style)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LaneRow(cells: List<GridCell<EventInstance>>, soften: Boolean, colW: Dp) {
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
cells.forEach { cell ->
|
||||
val event = cell.item
|
||||
if (event == null) {
|
||||
Spacer(GlanceModifier.width(colW * cell.cols).height(LANE_HEIGHT))
|
||||
} else {
|
||||
SpanBar(
|
||||
event = event,
|
||||
soften = soften,
|
||||
width = colW * cell.cols,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A single connected, rounded event bar [width] wide with its clipped title. */
|
||||
@Composable
|
||||
private fun SpanBar(event: EventInstance, soften: Boolean, width: Dp) {
|
||||
val context = LocalContext.current
|
||||
val dayFill = eventFill(event.color, dark = false, soften)
|
||||
val nightFill = eventFill(event.color, dark = true, soften)
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.width(width)
|
||||
.height(LANE_HEIGHT)
|
||||
.padding(horizontal = 1.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.cornerRadius(4.dp)
|
||||
.background(ColorProvider(day = dayFill, night = nightFill)),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
Text(
|
||||
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = ColorProvider(day = eventInk(dayFill), night = eventInk(nightFill)),
|
||||
fontSize = 9.sp,
|
||||
textDecoration = glanceDeclinedDecoration(event.isDeclined),
|
||||
),
|
||||
modifier = GlanceModifier.padding(horizontal = 3.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The "+N" row, drawn only for weeks that hide something, gaps merged as in a lane. */
|
||||
@Composable
|
||||
private fun OverflowRow(cells: List<GridCell<Int>>, colW: Dp) {
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
cells.forEach { cell ->
|
||||
if (cell.item == null) {
|
||||
Spacer(GlanceModifier.width(colW * cell.cols).height(LANE_HEIGHT))
|
||||
} else {
|
||||
Text(
|
||||
text = "+${cell.item}",
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp),
|
||||
modifier = GlanceModifier.width(colW).padding(start = 3.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PermissionMessage() {
|
||||
val context = LocalContext.current
|
||||
@@ -520,6 +370,7 @@ private fun PermissionMessage() {
|
||||
fontSize = 14.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
modifier = GlanceModifier.wallpaperBacking().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.syncWidgetRollover
|
||||
import de.jeanlucmakiola.floret.glance.reloadOnUpdate
|
||||
import de.jeanlucmakiola.floret.glance.restoreState
|
||||
|
||||
/**
|
||||
* Host-facing receiver for the month widget. Declared in the manifest with the
|
||||
@@ -30,6 +31,11 @@ class MonthWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
syncWidgetRollover(context)
|
||||
}
|
||||
|
||||
/** A backup restore or device transfer renumbered the widgets: keep each one's settings (#292). */
|
||||
override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) {
|
||||
restoreState(context, oldWidgetIds, newWidgetIds)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `updatePeriodMillis` backstop is the one wake-up the *system* still
|
||||
* owns, so it doubles as the alarm's self-heal: anything that drops a
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp"
|
||||
android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="@android:color/white"
|
||||
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
|
||||
</vector>
|
||||
@@ -1,6 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp"
|
||||
android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="@android:color/white"
|
||||
android:pathData="M19,3h-1V1h-2v2H8V1H6v2H5C3.89,3 3.01,3.9 3.01,5L3,19c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2zM19,19H5V8h14v11zM7,10h5v5H7z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<corners android:radius="4dp" />
|
||||
<solid android:color="@android:color/white" />
|
||||
</shape>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<corners android:radius="8dp" />
|
||||
<solid android:color="@android:color/white" />
|
||||
</shape>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
|
||||
<solid android:color="@android:color/white" />
|
||||
</shape>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:paddingHorizontal="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/bar_bg"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_bar_bg" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/bar_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:ellipsize="end"
|
||||
android:gravity="center_vertical"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:paddingHorizontal="3dp"
|
||||
android:textSize="9sp" />
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="2"
|
||||
android:paddingHorizontal="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/bar_bg"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_bar_bg" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/bar_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:ellipsize="end"
|
||||
android:gravity="center_vertical"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:paddingHorizontal="3dp"
|
||||
android:textSize="9sp" />
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="3"
|
||||
android:paddingHorizontal="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/bar_bg"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_bar_bg" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/bar_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:ellipsize="end"
|
||||
android:gravity="center_vertical"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:paddingHorizontal="3dp"
|
||||
android:textSize="9sp" />
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="4"
|
||||
android:paddingHorizontal="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/bar_bg"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_bar_bg" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/bar_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:ellipsize="end"
|
||||
android:gravity="center_vertical"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:paddingHorizontal="3dp"
|
||||
android:textSize="9sp" />
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="5"
|
||||
android:paddingHorizontal="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/bar_bg"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_bar_bg" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/bar_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:ellipsize="end"
|
||||
android:gravity="center_vertical"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:paddingHorizontal="3dp"
|
||||
android:textSize="9sp" />
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="6"
|
||||
android:paddingHorizontal="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/bar_bg"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_bar_bg" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/bar_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:ellipsize="end"
|
||||
android:gravity="center_vertical"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:paddingHorizontal="3dp"
|
||||
android:textSize="9sp" />
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="7"
|
||||
android:paddingHorizontal="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/bar_bg"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_bar_bg" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/bar_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:ellipsize="end"
|
||||
android:gravity="center_vertical"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:paddingHorizontal="3dp"
|
||||
android:textSize="9sp" />
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1" />
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="2" />
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="3" />
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="4" />
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="5" />
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="6" />
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="7" />
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/month_grid"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical" />
|
||||
@@ -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="15dp"
|
||||
android:orientation="horizontal"
|
||||
android:paddingBottom="1dp"
|
||||
android:weightSum="7" />
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/more"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center_vertical"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:paddingStart="3dp"
|
||||
android:textSize="9sp" />
|
||||
@@ -0,0 +1,360 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="horizontal">
|
||||
<FrameLayout
|
||||
android:id="@+id/strip_0"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:foreground="?android:attr/selectableItemBackground"
|
||||
android:padding="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_0"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_dim_0"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
<FrameLayout
|
||||
android:id="@+id/strip_1"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:foreground="?android:attr/selectableItemBackground"
|
||||
android:padding="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_1"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_dim_1"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
<FrameLayout
|
||||
android:id="@+id/strip_2"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:foreground="?android:attr/selectableItemBackground"
|
||||
android:padding="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_2"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_dim_2"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
<FrameLayout
|
||||
android:id="@+id/strip_3"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:foreground="?android:attr/selectableItemBackground"
|
||||
android:padding="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_3"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_dim_3"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
<FrameLayout
|
||||
android:id="@+id/strip_4"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:foreground="?android:attr/selectableItemBackground"
|
||||
android:padding="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_4"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_dim_4"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
<FrameLayout
|
||||
android:id="@+id/strip_5"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:foreground="?android:attr/selectableItemBackground"
|
||||
android:padding="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_5"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_dim_5"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
<FrameLayout
|
||||
android:id="@+id/strip_6"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:foreground="?android:attr/selectableItemBackground"
|
||||
android:padding="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_6"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_dim_6"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="18dp"
|
||||
android:orientation="horizontal">
|
||||
<FrameLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/today_bg_0"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:layout_gravity="center"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/widget_month_today_bg"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/day_0"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:includeFontPadding="false"
|
||||
android:textSize="11sp" />
|
||||
</FrameLayout>
|
||||
<FrameLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/today_bg_1"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:layout_gravity="center"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/widget_month_today_bg"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/day_1"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:includeFontPadding="false"
|
||||
android:textSize="11sp" />
|
||||
</FrameLayout>
|
||||
<FrameLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/today_bg_2"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:layout_gravity="center"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/widget_month_today_bg"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/day_2"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:includeFontPadding="false"
|
||||
android:textSize="11sp" />
|
||||
</FrameLayout>
|
||||
<FrameLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/today_bg_3"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:layout_gravity="center"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/widget_month_today_bg"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/day_3"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:includeFontPadding="false"
|
||||
android:textSize="11sp" />
|
||||
</FrameLayout>
|
||||
<FrameLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/today_bg_4"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:layout_gravity="center"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/widget_month_today_bg"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/day_4"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:includeFontPadding="false"
|
||||
android:textSize="11sp" />
|
||||
</FrameLayout>
|
||||
<FrameLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/today_bg_5"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:layout_gravity="center"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/widget_month_today_bg"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/day_5"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:includeFontPadding="false"
|
||||
android:textSize="11sp" />
|
||||
</FrameLayout>
|
||||
<FrameLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/today_bg_6"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:layout_gravity="center"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/widget_month_today_bg"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/day_6"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:includeFontPadding="false"
|
||||
android:textSize="11sp" />
|
||||
</FrameLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/week_lanes"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="2dp" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
@@ -441,6 +441,21 @@
|
||||
<string name="widget_prev_month">Previous month</string>
|
||||
<string name="widget_next_month">Next month</string>
|
||||
<string name="widget_today">Today</string>
|
||||
<string name="widget_config_background">Background</string>
|
||||
<string name="widget_config_background_solid">Solid</string>
|
||||
<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_content">Content</string>
|
||||
<string name="widget_config_range">Time range</string>
|
||||
<string name="widget_config_text_size">Text size</string>
|
||||
<string name="widget_config_load_failed">Couldn\'t read this widget\'s settings.</string>
|
||||
<string name="widget_config_preview_failed">Preview unavailable</string>
|
||||
<string name="widget_config_save_failed">Couldn\'t save the widget. Try again.</string>
|
||||
<!-- A widget's name and its number, when there are several of the same kind: "Calendula agenda 2" -->
|
||||
<string name="widget_numbered">%1$s %2$d</string>
|
||||
<!-- A placed widget's background and theme: "Tinted · Follow app" -->
|
||||
<string name="widget_appearance_summary">%1$s · %2$s</string>
|
||||
|
||||
<!-- Calendar filter (M3) -->
|
||||
<string name="filter_title">Calendars</string>
|
||||
@@ -554,7 +569,7 @@
|
||||
<!-- %1$d is the event text size as a percentage of the smallest step, e.g. 130. -->
|
||||
<string name="settings_widget_size_summary">Event text %1$d%%</string>
|
||||
<string name="settings_agenda_show_today">Always show today</string>
|
||||
<string name="settings_agenda_show_today_hint">Keep today at the top of the agenda and its widget, even once nothing is left today.</string>
|
||||
<string name="settings_agenda_show_today_hint">Keep today at the top of the agenda, even once nothing is left today. Each widget has its own setting.</string>
|
||||
<string name="settings_agenda_range_bar">Range bar</string>
|
||||
<string name="settings_agenda_range_bar_hint">Show a bar at the top of the agenda naming the dates shown, with a button to switch the range for the session</string>
|
||||
<string name="agenda_range_day">Today</string>
|
||||
@@ -645,12 +660,15 @@
|
||||
<string name="settings_event_form_subtitle">Default fields and behaviour</string>
|
||||
<string name="settings_notifications_subtitle">Reminders and delivery</string>
|
||||
<string name="settings_special_dates_subtitle">Contact birthdays & anniversaries</string>
|
||||
<string name="settings_widgets_subtitle">Agenda widget, Quick Settings tile</string>
|
||||
<string name="settings_widgets_subtitle">Your widgets, Quick Settings tile</string>
|
||||
<string name="settings_backup_subtitle">Export, import, automatic backup</string>
|
||||
|
||||
<!-- Widgets & tiles (#69) -->
|
||||
<string name="settings_section_widgets">Widgets & tiles</string>
|
||||
<string name="settings_widgets_hint">Settings for the home-screen widgets and the Quick Settings tile. Add a widget by long-pressing your home screen.</string>
|
||||
<string name="settings_widgets_hint">Each widget has its own look and options. Tap one below, or long-press it on your home screen and choose Edit. Add a widget by long-pressing your home screen.</string>
|
||||
<string name="settings_widgets_hint_legacy">Each widget has its own look and options. Tap one below to change it. Add a widget by long-pressing your home screen.</string>
|
||||
<string name="settings_widgets_placed">On your home screen</string>
|
||||
<string name="settings_widgets_none">No Calendula widgets on your home screen yet.</string>
|
||||
|
||||
<!-- Backup & restore (#69) — the rows themselves reuse the calendars_* strings -->
|
||||
<string name="settings_section_backup">Backup & restore</string>
|
||||
|
||||
@@ -7,4 +7,10 @@
|
||||
<item name="android:windowBackground">@color/window_background</item>
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
</style>
|
||||
|
||||
<!-- Widget config: drawn over the wallpaper, so the preview shows the real home screen. -->
|
||||
<style name="Theme.Calendula.WidgetConfig">
|
||||
<item name="android:windowBackground">@android:color/transparent</item>
|
||||
<item name="android:windowShowWallpaper">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
android:minResizeHeight="110dp"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:widgetCategory="home_screen"
|
||||
android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity"
|
||||
android:widgetFeatures="reconfigurable"
|
||||
android:description="@string/widget_agenda_label"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:previewLayout="@layout/widget_preview_agenda"
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
android:minResizeHeight="150dp"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:widgetCategory="home_screen"
|
||||
android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity"
|
||||
android:widgetFeatures="reconfigurable"
|
||||
android:description="@string/widget_month_label"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:previewLayout="@layout/widget_preview_month"
|
||||
|
||||
@@ -165,15 +165,12 @@ class SettingsPrefsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `agenda ranges default to month and round-trip independently`(@TempDir tempDir: Path) = runTest {
|
||||
fun `agenda screen range defaults to month and round-trips`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = testSettings(tempDir)
|
||||
assertThat(prefs.agendaScreenRange.first()).isEqualTo(AgendaRange.Month)
|
||||
assertThat(prefs.agendaWidgetRange.first()).isEqualTo(AgendaRange.Month)
|
||||
|
||||
prefs.setAgendaScreenRange(AgendaRange.Custom(14))
|
||||
prefs.setAgendaWidgetRange(AgendaRange.Week)
|
||||
assertThat(prefs.agendaScreenRange.first()).isEqualTo(AgendaRange.Custom(14))
|
||||
assertThat(prefs.agendaWidgetRange.first()).isEqualTo(AgendaRange.Week)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -15,25 +15,12 @@ class AgendaScaleTest {
|
||||
// SMALL is the default, so if this fails an agenda widget whose owner never
|
||||
// touched the size setting no longer looks as it did (#51).
|
||||
val m = metricsFor(WidgetSize.SMALL)
|
||||
assertThat(m.title).isEqualTo(16.sp)
|
||||
assertThat(m.dayHeader).isEqualTo(13.sp)
|
||||
assertThat(m.eventTitle).isEqualTo(14.sp)
|
||||
assertThat(m.eventTime).isEqualTo(12.sp)
|
||||
assertThat(m.placeholder).isEqualTo(14.sp)
|
||||
assertThat(m.message).isEqualTo(14.sp)
|
||||
assertThat(m.stripeH).isEqualTo(36.dp)
|
||||
assertThat(m.iconImage).isEqualTo(22.dp)
|
||||
assertThat(m.iconBox).isEqualTo(40.dp)
|
||||
assertThat(m.contentH).isEqualTo(36.dp)
|
||||
assertThat(m.rowVPad).isEqualTo(4.dp)
|
||||
assertThat(m.dayHeaderTopPad).isEqualTo(10.dp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the derived text indent matches the original hardcoded inset`() {
|
||||
// TEXT_INDENT replaced a hardcoded 19dp; it must still resolve to 19dp or
|
||||
// day headers and the placeholder line stop aligning with event titles.
|
||||
assertThat(TEXT_INDENT).isEqualTo(19.dp)
|
||||
assertThat(TEXT_INDENT).isEqualTo(ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP)
|
||||
}
|
||||
|
||||
// --- the ramp ------------------------------------------------------------
|
||||
@@ -43,17 +30,17 @@ class AgendaScaleTest {
|
||||
val steps = WidgetSize.entries.map(::metricsFor)
|
||||
|
||||
steps.zipWithNext { small, big ->
|
||||
assertThat(big.title.value).isAtLeast(small.title.value)
|
||||
assertThat(big.dayHeader.value).isAtLeast(small.dayHeader.value)
|
||||
assertThat(big.pillWeekday.value).isAtLeast(small.pillWeekday.value)
|
||||
assertThat(big.pillDay.value).isAtLeast(small.pillDay.value)
|
||||
assertThat(big.eventTitle.value).isAtLeast(small.eventTitle.value)
|
||||
assertThat(big.eventTime.value).isAtLeast(small.eventTime.value)
|
||||
assertThat(big.placeholder.value).isAtLeast(small.placeholder.value)
|
||||
assertThat(big.message.value).isAtLeast(small.message.value)
|
||||
assertThat(big.stripeH.value).isAtLeast(small.stripeH.value)
|
||||
assertThat(big.iconImage.value).isAtLeast(small.iconImage.value)
|
||||
assertThat(big.iconBox.value).isAtLeast(small.iconBox.value)
|
||||
assertThat(big.contentH.value).isAtLeast(small.contentH.value)
|
||||
assertThat(big.pillWidth.value).isAtLeast(small.pillWidth.value)
|
||||
assertThat(big.rowVPad.value).isAtLeast(small.rowVPad.value)
|
||||
assertThat(big.dayHeaderTopPad.value).isAtLeast(small.dayHeaderTopPad.value)
|
||||
assertThat(big.dayGap.value).isAtLeast(small.dayGap.value)
|
||||
assertThat(big.monthLabel.value).isAtLeast(small.monthLabel.value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,20 +58,28 @@ class AgendaScaleTest {
|
||||
// Guards against a future edit turning "more readable" into "absurd".
|
||||
val base = metricsFor(WidgetSize.SMALL)
|
||||
val top = metricsFor(WidgetSize.EXTRA_LARGE)
|
||||
assertThat(top.title.value / base.title.value).isLessThan(1.5f)
|
||||
assertThat(top.pillDay.value / base.pillDay.value).isLessThan(1.5f)
|
||||
assertThat(top.eventTitle.value / base.eventTitle.value).isLessThan(1.5f)
|
||||
}
|
||||
|
||||
// --- font scale ----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the stripe tracks the system font scale`() {
|
||||
// The stripe is Dp, the text beside it is sp: without this the two diverge
|
||||
// at large accessibility font settings and the stripe under-runs the row.
|
||||
fun `the row height tracks the system font scale`() {
|
||||
// The row height is Dp, the text in it is sp: without this the two diverge
|
||||
// at large accessibility font settings and the text outgrows the row.
|
||||
val m = metricsFor(WidgetSize.SMALL)
|
||||
assertThat(m.scaledForFont(1f).stripeH).isEqualTo(36.dp)
|
||||
assertThat(m.scaledForFont(1.3f).stripeH.value).isWithin(0.01f).of(46.8f)
|
||||
assertThat(m.scaledForFont(0.85f).stripeH.value).isWithin(0.01f).of(30.6f)
|
||||
assertThat(m.scaledForFont(1f).contentH).isEqualTo(36.dp)
|
||||
assertThat(m.scaledForFont(1.3f).contentH.value).isWithin(0.01f).of(46.8f)
|
||||
assertThat(m.scaledForFont(0.85f).contentH.value).isWithin(0.01f).of(30.6f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the date pill widens with the system font scale`() {
|
||||
// The pill's text is sp; a fixed-width pill clips "Wed" / "23" at large font settings.
|
||||
val m = metricsFor(WidgetSize.SMALL)
|
||||
assertThat(m.scaledForFont(2f).pillWidth).isEqualTo(m.pillWidth * 2)
|
||||
assertThat(m.scaledForFont(2f).rowHeight).isEqualTo(m.contentH * 2 + m.rowVPad * 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -99,7 +94,7 @@ class AgendaScaleTest {
|
||||
// double-count it.
|
||||
val m = metricsFor(WidgetSize.MEDIUM)
|
||||
val scaled = m.scaledForFont(1.3f)
|
||||
assertThat(scaled.title).isEqualTo(m.title)
|
||||
assertThat(scaled.pillDay).isEqualTo(m.pillDay)
|
||||
assertThat(scaled.eventTitle).isEqualTo(m.eventTitle)
|
||||
assertThat(scaled.rowVPad).isEqualTo(m.rowVPad)
|
||||
}
|
||||
|
||||
@@ -13,21 +13,18 @@ import kotlinx.datetime.Month
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.YearMonth
|
||||
import kotlinx.datetime.plus
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/** The month widget's view budget (#372). */
|
||||
/** How the month widget seats a week's events into lanes (#372). */
|
||||
class MonthGridTest {
|
||||
|
||||
private val zone = TimeZone.UTC
|
||||
private val jul26 = YearMonth(2026, Month.JULY)
|
||||
private val today = LocalDate(2026, 7, 15)
|
||||
|
||||
/** Monday Jul 6 to Sunday Jul 12. */
|
||||
private val monday = LocalDate(2026, 7, 6)
|
||||
|
||||
private val android16 = ViewIds(sdkInt = 36)
|
||||
private val android10 = ViewIds(sdkInt = 29)
|
||||
|
||||
private fun weeks(events: List<EventInstance>): List<MonthWeek> =
|
||||
layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone)
|
||||
|
||||
@@ -43,33 +40,37 @@ class MonthGridTest {
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<WeekLayout>.cost(ids: ViewIds) = sumOf { it.viewIds(today, ids) }
|
||||
|
||||
@Test
|
||||
fun `a quiet month keeps every lane it uses`() {
|
||||
val layouts = layoutWeeks(busyWeeks(perDay = 1), today, android16)
|
||||
assertThat(layouts.map { it.laneCap }.distinct()).containsExactly(1)
|
||||
fun `a week that fits keeps every lane`() {
|
||||
val layouts = fitWeeks(busyWeeks(perDay = 2), lanes = 3)
|
||||
assertThat(layouts.map { it.laneCap }.distinct()).containsExactly(3)
|
||||
assertThat(layouts.all { it.overflow.isEmpty() }).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a full month folds lanes until it fits, on every Android version`() {
|
||||
for (ids in listOf(android16, android10)) {
|
||||
val weeks = busyWeeks(perDay = 5)
|
||||
assertThat(weeks.map { WeekLayout(it, MAX_LANES) }.cost(ids)).isGreaterThan(VIEW_BUDGET - ids.fixed)
|
||||
|
||||
val layouts = layoutWeeks(weeks, today, ids)
|
||||
assertThat(layouts.cost(ids)).isAtMost(VIEW_BUDGET - ids.fixed)
|
||||
assertThat(layouts.any { it.laneCap < MAX_LANES }).isTrue()
|
||||
}
|
||||
fun `a week that doesn't fit gives its last lane to the overflow`() {
|
||||
val layouts = fitWeeks(busyWeeks(perDay = 5), lanes = 3)
|
||||
assertThat(layouts.map { it.laneCap }.distinct()).containsExactly(2)
|
||||
assertThat(layouts.all { it.overflow.isNotEmpty() }).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `older Android folds more, since a sized view costs it two ids`() {
|
||||
val weeks = busyWeeks(perDay = 3)
|
||||
val newer = layoutWeeks(weeks, today, android16).sumOf { it.laneCap }
|
||||
val older = layoutWeeks(weeks, today, android10).sumOf { it.laneCap }
|
||||
assertThat(older).isLessThan(newer)
|
||||
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)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lanes stop at the cap however tall the week`() {
|
||||
assertThat(lanesFitting(1000.dp)).isEqualTo(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)
|
||||
assertThat(layouts.none { it.showsOverflow }).isTrue()
|
||||
assertThat(layouts.all { it.lanes.isEmpty() }).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -83,7 +84,7 @@ class MonthGridTest {
|
||||
@Test
|
||||
fun `a multi-day bar is one cell as wide as its days`() {
|
||||
val bar = allDayEvent(monday.plus(1, DateTimeUnit.DAY), monday.plus(3, DateTimeUnit.DAY), id = 1)
|
||||
val layout = WeekLayout(weeks(listOf(bar))[1], MAX_LANES)
|
||||
val layout = WeekLayout(weeks(listOf(bar))[1], laneCap = 3)
|
||||
assertThat(layout.lanes).hasSize(1)
|
||||
assertThat(layout.lanes[0]).containsExactly(GridCell(null, 1), GridCell(bar, 3), GridCell(null, 3)).inOrder()
|
||||
}
|
||||
|
||||
+1
-1
Submodule floret-kit updated: 96509d0f9d...f2ffaed925
Reference in New Issue
Block a user