Compare commits

...

3 Commits

Author SHA1 Message Date
79e083b4d4 Merge pull request 'feat: configurable default view + widget-aware back navigation' (!35) from feat/view-back-stack into release/v2.9.0
Reviewed-on: #35
2026-06-25 06:25:15 +00:00
119a8afe8e feat(nav): interactive month widget + history-retracing back
All checks were successful
Translations / check (pull_request) Successful in 28s
CI / ci (pull_request) Successful in 4m18s
On-device review follow-ups:

- Month widget grid is now tappable. Day numbers open that day and event
  bars open the event's detail, both rooted in the month view so back
  returns to the grid. Previously only the prev/next/today header
  controls responded — the grid cells were never clickable.

- Pill/drawer view switches now build a visit history instead of
  collapsing to the default view. Back retraces the views you moved
  through (a not-yet-visited view is pushed; revisiting one collapses the
  loop back to it), down to the default, then exits. Widget launches
  still reset to their own view context.

Refs #1, #2 (reported by @devinside).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:17:23 +02:00
b537e143f8 feat(nav): default view setting + widget-aware back stack
Replace CalendarHost's single global view slot with a top-level view
back stack rooted at a user-configurable default view. A lateral move
(pill/drawer/widget) replaces the non-home top; a date tap drills the
day view on top; a base-level BackHandler pops one level until only the
home view remains, then the system exits.

Widgets now carry their source view (EXTRA_SOURCE_VIEW) so a launch
roots the stack in that widget's view: backing out of a day/event opened
from the agenda widget returns to Agenda, and from the month widget to
Month, instead of always landing on Week. Reminder taps keep the
separate detail-key channel and leave the base view untouched.

Add a Default view setting (Settings -> Appearance) backing the stack's
home view; defaults to Week so existing users see no change.

Resolves the two Codeberg reports from @devinside:
- #1 [FR] Option to set default view
- #2 [Bug] Widget UX improvement

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:07:00 +02:00
14 changed files with 401 additions and 32 deletions

View File

@@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Choose the view Calendula opens on. A new **Default view** setting (Settings →
Appearance) lets you pick Month, Week, Day or Agenda as the view shown each
time you start the app, instead of always opening on Week. Thanks to
@devinside for the suggestion ([#1]).
- The month widget is now interactive: tap any day to open it, or tap an event to
open its details. Previously only the month's prev/next/today controls
responded. Thanks to @devinside for spotting this ([#2]).
### Fixed
- Home-screen widgets now back out the way you came in. A day or event opened
from the Agenda widget keeps you in the agenda context, and from the Month
widget in the month context — pressing Back returns there instead of dropping
you on the week view. More broadly, the app now keeps a real view history:
switching views and drilling into a day are retraced by Back one step at a
time, down to your default view before the app exits. Thanks to @devinside for
reporting ([#2]).
## [2.8.0] — 2026-06-23
### Added
@@ -572,3 +592,6 @@ automatically, with zero telemetry and no internet permission.
- Gitea release workflow: signed release APK + F-Droid metadata sync to Hetzner
- F-Droid metadata stubs (DE + EN short/full descriptions)
- `.planning/` project-tracking documents
[#1]: https://codeberg.org/jlmakiola/calendula/issues/1
[#2]: https://codeberg.org/jlmakiola/calendula/issues/2

View File

@@ -22,6 +22,7 @@ import de.jeanlucmakiola.calendula.data.crash.CrashReporter
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.ui.RootScreen
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
@@ -140,18 +141,38 @@ class MainActivity : AppCompatActivity() {
return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
}
private fun Intent.navRequestOrNull(): WidgetNavRequest? = when {
// Launcher long-press "New event" shortcut. Static shortcut intents
// can't carry typed extras, so the action alone signals create-on-today.
action == ACTION_NEW_EVENT -> WidgetNavRequest.Create(null)
getBooleanExtra(EXTRA_CREATE, false) ->
WidgetNavRequest.Create(getStringExtra(EXTRA_DATE_ISO))
getStringExtra(EXTRA_DATE_ISO) != null ->
WidgetNavRequest.OpenDate(getStringExtra(EXTRA_DATE_ISO)!!)
else -> null
private fun Intent.navRequestOrNull(): WidgetNavRequest? {
val source = sourceViewOrNull()
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
return when {
// Launcher long-press "New event" shortcut. Static shortcut intents
// can't carry typed extras, so the action alone signals create-on-today.
action == ACTION_NEW_EVENT -> WidgetNavRequest.Create(null)
getBooleanExtra(EXTRA_CREATE, false) ->
WidgetNavRequest.Create(getStringExtra(EXTRA_DATE_ISO))
// A widget event tap carries both the occurrence and its source view;
// reminders (no source) fall through to [detailKeyOrNull] instead.
source != null && eventId != -1L -> WidgetNavRequest.OpenEvent(
eventId = eventId,
beginMillis = getLongExtra(EXTRA_BEGIN_MILLIS, 0L),
endMillis = getLongExtra(EXTRA_END_MILLIS, 0L),
source = source,
)
source != null && getStringExtra(EXTRA_DATE_ISO) != null ->
WidgetNavRequest.OpenDate(getStringExtra(EXTRA_DATE_ISO)!!, source)
else -> null
}
}
/** The view the launching widget represents, if any (see [EXTRA_SOURCE_VIEW]). */
private fun Intent.sourceViewOrNull(): CalendarView? =
getStringExtra(EXTRA_SOURCE_VIEW)
?.let { name -> CalendarView.entries.firstOrNull { it.name == name } }
private fun Intent.detailKeyOrNull(): LongArray? {
// A widget event tap (source present) is routed through [navRequestOrNull]
// so it can also set the base view; only sourceless reminder taps land here.
if (sourceViewOrNull() != null) return null
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
if (eventId == -1L) return null
return longArrayOf(
@@ -168,6 +189,10 @@ class MainActivity : AppCompatActivity() {
private const val EXTRA_DATE_ISO = "de.jeanlucmakiola.calendula.extra.DATE_ISO"
private const val EXTRA_CREATE = "de.jeanlucmakiola.calendula.extra.CREATE"
// The [CalendarView] (by name) of the widget a launch came from. Roots the
// in-app back stack in that view; absent for non-widget launches (reminders).
private const val EXTRA_SOURCE_VIEW = "de.jeanlucmakiola.calendula.extra.SOURCE_VIEW"
// Fired by the launcher long-press "New event" shortcut (res/xml/
// shortcuts.xml hardcodes this string — keep the two in sync).
const val ACTION_NEW_EVENT = "de.jeanlucmakiola.calendula.action.NEW_EVENT"
@@ -190,14 +215,35 @@ class MainActivity : AppCompatActivity() {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
/** Open the day view anchored on [date] (home-screen widgets). */
fun openDateIntent(context: Context, date: LocalDate): Intent =
/**
* Open the day view anchored on [date], drilled in over the launching
* widget's [source] view (home-screen widgets). Backing out of the day
* returns to [source], then to the default home view.
*/
fun openDateIntent(context: Context, date: LocalDate, source: CalendarView): Intent =
Intent(context, MainActivity::class.java).apply {
data = "calendula://date/$date".toUri()
putExtra(EXTRA_DATE_ISO, date.toString())
putExtra(EXTRA_SOURCE_VIEW, source.name)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
/**
* Open one occurrence's detail from a widget event tap, rooting the back
* stack in the widget's [source] view. Same occurrence-key shape as
* [eventDetailIntent]; the [source] extra is what distinguishes a widget
* tap (sets the base view) from a reminder tap (leaves it untouched).
*/
fun openEventIntent(
context: Context,
eventId: Long,
beginMillis: Long,
endMillis: Long,
source: CalendarView,
): Intent = eventDetailIntent(context, eventId, beginMillis, endMillis).apply {
putExtra(EXTRA_SOURCE_VIEW, source.name)
}
/** Open the create-event form prefilled for [date] (home-screen widgets). */
fun openCreateIntent(context: Context, date: LocalDate): Intent =
Intent(context, MainActivity::class.java).apply {

View File

@@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.datetime.DayOfWeek
@@ -69,6 +70,20 @@ class SettingsPrefs @Inject constructor(
store.edit { it[WEEK_START_KEY] = pref.name }
}
/**
* The calendar view the app opens on (M1). Defaults to [CalendarView.Week] —
* the historical hard-coded startup view — so existing users see no change
* until they pick another. Also the bottom of the in-app view back stack, so
* back from any other view returns here before exiting.
*/
val defaultView: Flow<CalendarView> = store.data.map { prefs ->
prefs[DEFAULT_VIEW_KEY].toEnum(CalendarView.Week)
}
suspend fun setDefaultView(view: CalendarView) {
store.edit { it[DEFAULT_VIEW_KEY] = view.name }
}
/**
* Optional event-form fields shown by default (the rest hide behind
* "more fields"). Stored comma-joined by enum name: an absent key means
@@ -244,6 +259,7 @@ class SettingsPrefs @Inject constructor(
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")

View File

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.ui
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -12,15 +13,21 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.drillToDay
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.selectView
import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
import de.jeanlucmakiola.calendula.ui.day.DayScreen
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
@@ -35,14 +42,24 @@ import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
/**
* Holds the active top-level view (spec M1) and swaps between the calendar
* Holds the top-level view back stack (spec M1) and swaps between the calendar
* screens. Each screen owns its own ViewModel and date anchor; the view-switcher
* pill in their top bars writes back here via [onSelectView].
*
* The stack's bottom is the user's [CalendarHostViewModel.defaultView] home view.
* A lateral switch (pill / drawer) builds a visit history so back retraces it
* (see [selectView]); a widget launch resets the stack to its own view; a date
* tap drills the day view on top. Pressing back pops one level, and the
* base-level [BackHandler] hands off to the system to exit once only the home
* view remains. So back from a widget-opened screen returns to that widget's
* view, then home; and back through pill switches walks the views in reverse.
*
* [requestedDetailKey] is an externally requested occurrence (a tapped
* reminder notification routed through MainActivity): it opens the detail
* overlay exactly like an event tap and is cleared via [onDetailKeyConsumed]
* so a later recomposition can't re-open it.
* so a later recomposition can't re-open it. (A widget event tap instead arrives
* as [WidgetNavRequest.OpenEvent], which also roots the back stack in the
* widget's view.)
*/
@Composable
fun CalendarHost(
@@ -53,15 +70,24 @@ fun CalendarHost(
onWidgetNavConsumed: () -> Unit = {},
requestedImportUri: android.net.Uri? = null,
onImportConsumed: () -> Unit = {},
viewModel: CalendarHostViewModel = hiltViewModel(),
) {
var view by rememberSaveable { mutableStateOf(CalendarView.Week) }
val onSelectView: (CalendarView) -> Unit = { view = it }
// Wait for the persisted default view before seeding the stack, so the app
// opens straight on the user's choice instead of flashing a placeholder and
// correcting it. Brief blank first frame, matching the onboarding gate above.
val defaultView = viewModel.defaultView.collectAsStateWithLifecycle().value ?: return
var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
mutableStateOf(listOf(defaultView))
}
val view = viewStack.last()
val onSelectView: (CalendarView) -> Unit = { viewStack = viewStack.selectView(it) }
// Tapping a day in the month grid opens the day view anchored to that date.
var pendingDayIso by rememberSaveable { mutableStateOf<String?>(null) }
val onOpenDay: (LocalDate) -> Unit = { date ->
pendingDayIso = date.toString()
view = CalendarView.Day
viewStack = viewStack.drillToDay()
}
// The event-detail screen (S4) is a full-screen destination hoisted here so
@@ -156,17 +182,30 @@ fun CalendarHost(
importForm = null
}
// A home-screen widget launch asks to open a date (→ day view) or start a
// create. Handled once and cleared, mirroring [requestedDetailKey].
// A home-screen widget launch asks to open a date (→ day view), open an
// event's detail, or start a create. Handled once and cleared, mirroring
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
// view so backing out returns there (then home), not to the default.
LaunchedEffect(widgetNavRequest) {
when (val req = widgetNavRequest) {
is WidgetNavRequest.OpenDate -> {
// Reveal the day view: drop any overlay that would cover it, so
// an external date-open doesn't land under an open Settings/form.
// Drill the day view in over the widget's view: drop any overlay
// that would cover it, so the open doesn't land under Settings/form.
dismissCoveringOverlays()
createDateIso = null
pendingDayIso = req.dateIso
view = CalendarView.Day
viewStack = viewBaseStack(defaultView, req.source).drillToDay()
onWidgetNavConsumed()
}
is WidgetNavRequest.OpenEvent -> {
// Root the stack in the widget's view, then open the occurrence
// detail over it (same key shape as a tapped event / reminder).
dismissCoveringOverlays()
createDateIso = null
viewStack = viewBaseStack(defaultView, req.source)
val key = longArrayOf(req.eventId, req.beginMillis, req.endMillis)
heldKey = key
detailKey = key
onWidgetNavConsumed()
}
is WidgetNavRequest.Create -> {
@@ -189,6 +228,16 @@ fun CalendarHost(
val slideSpec = rememberCalendarSlideSpec()
// Base-level back: pop the view stack while no overlay covers it (each overlay
// owns its own BackHandler and takes precedence). Disabled at the home view,
// so back there falls through to the system and exits the app.
val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null ||
editKey != null || showSettings || showCalendars || importUri != null ||
importForm != null
BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) {
viewStack = viewStack.dropLast(1)
}
Box(modifier = modifier.fillMaxSize()) {
when (view) {
CalendarView.Week -> WeekScreen(
@@ -338,3 +387,9 @@ fun CalendarHost(
}
}
}
/** Persists the view back stack across config change / process death by ordinal. */
private val viewStackSaver = listSaver<List<CalendarView>, Int>(
save = { stack -> stack.map(CalendarView::ordinal) },
restore = { ordinals -> ordinals.map { CalendarView.entries[it] } },
)

View File

@@ -0,0 +1,29 @@
package de.jeanlucmakiola.calendula.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
/**
* Supplies [CalendarHost] with the user's default startup view (M1). Held as a
* nullable [StateFlow] so the host can wait for DataStore's first emission before
* seeding its view back stack — rendering nothing for that first frame rather
* than flashing the old hard-coded Week view and then correcting it.
*/
@HiltViewModel
class CalendarHostViewModel @Inject constructor(
prefs: SettingsPrefs,
) : ViewModel() {
val defaultView: StateFlow<CalendarView?> = prefs.defaultView.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = null,
)
}

View File

@@ -1,14 +1,28 @@
package de.jeanlucmakiola.calendula.ui
import de.jeanlucmakiola.calendula.ui.common.CalendarView
/**
* A navigation a home-screen widget asked the app to perform when launched.
* Parsed from the launch intent in MainActivity and consumed once by
* [CalendarHost] (event taps reuse the existing reminder detail-key channel, so
* they are not modelled here).
* [CalendarHost]. Every request carries the [source] view of the widget it came
* from (the agenda widget → [CalendarView.Agenda], the month widget →
* [CalendarView.Month]) so the in-app back stack roots itself in that view:
* backing out of the opened date/event returns to the widget's own view, not the
* default home. (Reminder notifications are not widgets — they keep the separate
* detail-key channel and leave the base view untouched.)
*/
sealed interface WidgetNavRequest {
/** Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date). */
data class OpenDate(val dateIso: String) : WidgetNavRequest
/** Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date), over [source]. */
data class OpenDate(val dateIso: String, val source: CalendarView) : WidgetNavRequest
/** Open one occurrence's detail (an agenda-widget event tap), over [source]. */
data class OpenEvent(
val eventId: Long,
val beginMillis: Long,
val endMillis: Long,
val source: CalendarView,
) : WidgetNavRequest
/** Open the create-event form prefilled for [dateIso] (today when null). */
data class Create(val dateIso: String?) : WidgetNavRequest

View File

@@ -49,3 +49,31 @@ fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): Calend
if (i < 0) return available.firstOrNull() ?: CalendarView.Month
return available[(i + 1) % available.size]
}
/**
* The top-level view back stack (bottom → top): the [default] home view always
* sits at the bottom. Pressing back pops one level until only the home view
* remains, then the system exits the app. Three operations move it:
*
* - [selectView] — a lateral switch (the pill or the drawer). It keeps a visit
* history so back retraces it: switching to a not-yet-visited view pushes it on
* top; switching to one already in the stack pops back to it (collapsing the
* loop, so the depth stays bounded by the number of distinct views). Selecting
* the home view collapses to just `[default]`.
* - [viewBaseStack] — the stack a widget launch resets to: just `[default]` when
* the widget's view is the home view, else `[default, source]`. A widget entry
* starts a fresh context rather than extending in-app history.
* - [drillToDay] — pushes the day view on top (a date tap from the month grid or
* a widget), unless the day view is already current.
*/
fun List<CalendarView>.selectView(target: CalendarView): List<CalendarView> {
val existing = indexOf(target)
return if (existing >= 0) take(existing + 1) else this + target
}
fun viewBaseStack(default: CalendarView, source: CalendarView): List<CalendarView> =
if (source == default) listOf(default) else listOf(default, source)
/** Push the day view as a drill-in over the current stack (no-op if already on it). */
fun List<CalendarView>.drillToDay(): List<CalendarView> =
if (lastOrNull() == CalendarView.Day) this else this + CalendarView.Day

View File

@@ -90,6 +90,8 @@ import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.labelRes
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.OptionPicker
import de.jeanlucmakiola.calendula.ui.common.Position
@@ -404,6 +406,7 @@ private fun AppearanceScreen(
) {
var showTheme by remember { mutableStateOf(false) }
var showWeekStart by remember { mutableStateOf(false) }
var showDefaultView by remember { mutableStateOf(false) }
CollapsingScaffold(
title = stringResource(R.string.settings_section_appearance),
@@ -415,6 +418,12 @@ private fun AppearanceScreen(
position = Position.Top,
onClick = { showTheme = true },
)
GroupedRow(
title = stringResource(R.string.settings_default_view),
summary = stringResource(state.defaultView.labelRes),
position = Position.Middle,
onClick = { showDefaultView = true },
)
GroupedRow(
title = stringResource(R.string.settings_dynamic_color),
summary = if (state.dynamicColorAvailable) {
@@ -464,6 +473,16 @@ private fun AppearanceScreen(
onDismiss = { showWeekStart = false },
)
}
if (showDefaultView) {
OptionPicker(
title = stringResource(R.string.settings_default_view),
options = IMPLEMENTED_VIEWS,
selected = state.defaultView,
label = { stringResource(it.labelRes) },
onSelect = viewModel::setDefaultView,
onDismiss = { showDefaultView = false },
)
}
}
@Composable

View File

@@ -5,6 +5,7 @@ import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.ui.common.CalendarView
/**
* Settings screen state (M4). Persisted preferences are instant to read, so
@@ -17,6 +18,8 @@ data class SettingsUiState(
val dynamicColor: Boolean = true,
val dynamicColorAvailable: Boolean = true,
val weekStart: WeekStartPref = WeekStartPref.AUTO,
/** The calendar view the app opens on, and the home of the view back stack (M1). */
val defaultView: CalendarView = CalendarView.Week,
/** Optional event-form fields shown by default (rest behind "more fields"). */
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
/** Whether Calendula posts reminder notifications (v1.4). */

View File

@@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -72,8 +73,10 @@ class SettingsViewModel @Inject constructor(
) { overrides, allDayOverrides, calendars ->
ReminderOverrides(overrides, allDayOverrides, calendars)
},
) { base, defaults, overrides ->
prefs.defaultView,
) { base, defaults, overrides, defaultView ->
base.copy(
defaultView = defaultView,
allowColorOnUnsupportedCalendars = defaults.allowColor,
defaultReminderMinutes = defaults.defaultReminder,
defaultAllDayReminderMinutes = defaults.allDayReminder,
@@ -115,6 +118,10 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setWeekStart(pref) }
}
fun setDefaultView(view: CalendarView) {
viewModelScope.launch { prefs.setDefaultView(view) }
}
fun setFormFieldDefault(field: EventFormField, enabled: Boolean) {
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
}

View File

@@ -41,6 +41,7 @@ import androidx.glance.text.TextStyle
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
@@ -185,7 +186,11 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
modifier = GlanceModifier
.fillMaxWidth()
.padding(start = 8.dp, end = 8.dp, top = 10.dp, bottom = 4.dp)
.clickable(actionStartActivity(MainActivity.openDateIntent(context, date))),
.clickable(
actionStartActivity(
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
),
),
)
}
@@ -199,11 +204,12 @@ private fun EventRow(event: EventInstance, dark: Boolean) {
.padding(horizontal = 4.dp, vertical = 4.dp)
.clickable(
actionStartActivity(
MainActivity.eventDetailIntent(
MainActivity.openEventIntent(
context = context,
eventId = event.eventId,
beginMillis = event.start.toEpochMilliseconds(),
endMillis = event.end.toEpochMilliseconds(),
source = CalendarView.Agenda,
),
),
),

View File

@@ -49,6 +49,7 @@ 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.pastelize
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
@@ -209,7 +210,9 @@ private fun MonthHeader(label: String) {
resId = R.drawable.ic_widget_today,
contentDescription = context.getString(R.string.widget_today),
onClick = GlanceModifier.clickable(
actionStartActivity(MainActivity.openDateIntent(context, today(systemZone()))),
actionStartActivity(
MainActivity.openDateIntent(context, today(systemZone()), CalendarView.Month),
),
),
)
HeaderIcon(
@@ -300,8 +303,16 @@ private fun WeekRow(
@Composable
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
val context = LocalContext.current
Box(
modifier = GlanceModifier.width(colW).height(DAY_NUMBER_HEIGHT),
modifier = GlanceModifier
.width(colW)
.height(DAY_NUMBER_HEIGHT)
// Tap a day number to open that day, rooted in the month view so back
// returns to the month grid.
.clickable(
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month)),
),
contentAlignment = Alignment.Center,
) {
Box(
@@ -353,7 +364,24 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
@Composable
private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) {
val context = LocalContext.current
Box(modifier = GlanceModifier.width(width).height(LANE_HEIGHT).padding(horizontal = 1.dp)) {
Box(
modifier = GlanceModifier
.width(width)
.height(LANE_HEIGHT)
.padding(horizontal = 1.dp)
// Tap an event bar to open its detail, rooted in the month view.
.clickable(
actionStartActivity(
MainActivity.openEventIntent(
context = context,
eventId = event.eventId,
beginMillis = event.start.toEpochMilliseconds(),
endMillis = event.end.toEpochMilliseconds(),
source = CalendarView.Month,
),
),
),
) {
Box(
modifier = GlanceModifier
.fillMaxSize()

View File

@@ -264,6 +264,7 @@
<string name="settings_theme_system">System</string>
<string name="settings_theme_light">Light</string>
<string name="settings_theme_dark">Dark</string>
<string name="settings_default_view">Default view</string>
<string name="settings_dynamic_color">Dynamic colour</string>
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string>
<string name="settings_week_start">Week starts on</string>
@@ -299,7 +300,7 @@
<string name="settings_language">App language</string>
<string name="settings_language_auto">System default</string>
<!-- Hub category subtitles -->
<string name="settings_appearance_subtitle">Theme, dynamic colour, week start</string>
<string name="settings_appearance_subtitle">Theme, default view, week start</string>
<string name="settings_event_form_subtitle">Default fields for new events</string>
<string name="settings_notifications_subtitle">Event reminders</string>
<string name="settings_section_about">About</string>

View File

@@ -0,0 +1,94 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/**
* The pure back-stack rules behind [CalendarHost]'s top-level navigation:
* the home view is always the bottom, a lateral move keeps the stack one deep,
* and a date tap drills the day view on top. Verifies the back sequences the
* widget-UX fix promises (e.g. agenda widget → day → back → agenda → back → home).
*/
class ViewBackStackTest {
@Test
fun `a widget whose view is the home view resets to just the home`() {
assertThat(viewBaseStack(CalendarView.Week, CalendarView.Week))
.containsExactly(CalendarView.Week)
}
@Test
fun `a widget launch resets to its view over the home`() {
assertThat(viewBaseStack(CalendarView.Week, CalendarView.Month))
.containsExactly(CalendarView.Week, CalendarView.Month)
.inOrder()
}
@Test
fun `pill switches build a visit history that back retraces`() {
// Agenda → Day → Week → Month, each a new view, stacks the full history.
val stack = listOf(CalendarView.Agenda)
.selectView(CalendarView.Day)
.selectView(CalendarView.Week)
.selectView(CalendarView.Month)
assertThat(stack).containsExactly(
CalendarView.Agenda, CalendarView.Day, CalendarView.Week, CalendarView.Month,
).inOrder()
// Back walks the views in reverse, not straight to the home view.
assertThat(stack.dropLast(1).last()).isEqualTo(CalendarView.Week)
}
@Test
fun `selecting an already-visited view collapses the loop back to it`() {
val stack = listOf(CalendarView.Week, CalendarView.Agenda, CalendarView.Day)
.selectView(CalendarView.Agenda)
assertThat(stack).containsExactly(CalendarView.Week, CalendarView.Agenda).inOrder()
}
@Test
fun `selecting the home view collapses to just the home`() {
val stack = listOf(CalendarView.Week, CalendarView.Agenda, CalendarView.Day)
.selectView(CalendarView.Week)
assertThat(stack).containsExactly(CalendarView.Week)
}
@Test
fun `drilling pushes the day view on top`() {
assertThat(listOf(CalendarView.Week, CalendarView.Month).drillToDay())
.containsExactly(CalendarView.Week, CalendarView.Month, CalendarView.Day)
.inOrder()
}
@Test
fun `drilling is a no-op when the day view is already current`() {
val stack = listOf(CalendarView.Week, CalendarView.Day)
assertThat(stack.drillToDay()).isEqualTo(stack)
}
@Test
fun `agenda widget date tap backs out agenda then home`() {
// openDate(source = Agenda): root in Agenda over the Week home, then drill.
val stack = viewBaseStack(CalendarView.Week, CalendarView.Agenda).drillToDay()
assertThat(stack)
.containsExactly(CalendarView.Week, CalendarView.Agenda, CalendarView.Day)
.inOrder()
// Pressing back pops one level at a time down to the home view.
assertThat(stack.dropLast(1)).containsExactly(CalendarView.Week, CalendarView.Agenda).inOrder()
assertThat(stack.dropLast(2)).containsExactly(CalendarView.Week)
}
@Test
fun `month widget date tap backs out month then home`() {
val stack = viewBaseStack(CalendarView.Week, CalendarView.Month).drillToDay()
assertThat(stack.last()).isEqualTo(CalendarView.Day)
assertThat(stack.dropLast(1).last()).isEqualTo(CalendarView.Month)
assertThat(stack.first()).isEqualTo(CalendarView.Week)
}
@Test
fun `a widget whose view is the home view does not duplicate the home`() {
// Default = Agenda, agenda widget event tap → just the home, no extra layer.
assertThat(viewBaseStack(CalendarView.Agenda, CalendarView.Agenda))
.containsExactly(CalendarView.Agenda)
}
}