Compare commits
7 Commits
v2.8.0
...
release/v2
| Author | SHA1 | Date | |
|---|---|---|---|
| 024deadf78 | |||
| 599c3c03e7 | |||
| 5d48c90408 | |||
| c6d15d0118 | |||
| 79e083b4d4 | |||
| 119a8afe8e | |||
| b537e143f8 |
38
CHANGELOG.md
38
CHANGELOG.md
@@ -5,6 +5,41 @@ 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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [2.9.0] — 2026-06-25
|
||||||
|
|
||||||
|
### 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]).
|
||||||
|
- Predictive back. Swiping back from a screen — an event, the editor, search or
|
||||||
|
settings — now follows your finger, shrinking the screen to preview what's
|
||||||
|
behind so you can see where Back will take you before you let go; slide it back
|
||||||
|
to cancel. The preview needs Android 14 or newer; on older versions Back works
|
||||||
|
as before.
|
||||||
|
- Respect for "Remove animations". If you've turned animations off in your
|
||||||
|
device's Accessibility settings, Calendula now honours it everywhere: motion
|
||||||
|
collapses to a quick fade and the back preview is skipped.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Smoother, more consistent motion throughout. Expanding sections, list updates
|
||||||
|
in search and agenda, and the onboarding screens now animate the same way
|
||||||
|
across the app, instead of some places sliding or growing while others popped
|
||||||
|
in. Switching between Month, Week, Day and Agenda now cross-fades rather than
|
||||||
|
snapping, while paging within a view keeps its slide.
|
||||||
|
|
||||||
|
### 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
|
## [2.8.0] — 2026-06-23
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -572,3 +607,6 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
- Gitea release workflow: signed release APK + F-Droid metadata sync to Hetzner
|
- Gitea release workflow: signed release APK + F-Droid metadata sync to Hetzner
|
||||||
- F-Droid metadata stubs (DE + EN short/full descriptions)
|
- F-Droid metadata stubs (DE + EN short/full descriptions)
|
||||||
- `.planning/` project-tracking documents
|
- `.planning/` project-tracking documents
|
||||||
|
|
||||||
|
[#1]: https://codeberg.org/jlmakiola/calendula/issues/1
|
||||||
|
[#2]: https://codeberg.org/jlmakiola/calendula/issues/2
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ android {
|
|||||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||||
versionCode = 20800
|
versionCode = 20900
|
||||||
versionName = "2.8.0"
|
versionName = "2.9.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
<application
|
<application
|
||||||
android:name=".CalendulaApp"
|
android:name=".CalendulaApp"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
android:enableOnBackInvokedCallback="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.ui.RootScreen
|
import de.jeanlucmakiola.calendula.ui.RootScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
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.CrashReportActivity
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
||||||
@@ -140,18 +141,38 @@ class MainActivity : AppCompatActivity() {
|
|||||||
return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
|
return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Intent.navRequestOrNull(): WidgetNavRequest? = when {
|
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
|
// Launcher long-press "New event" shortcut. Static shortcut intents
|
||||||
// can't carry typed extras, so the action alone signals create-on-today.
|
// can't carry typed extras, so the action alone signals create-on-today.
|
||||||
action == ACTION_NEW_EVENT -> WidgetNavRequest.Create(null)
|
action == ACTION_NEW_EVENT -> WidgetNavRequest.Create(null)
|
||||||
getBooleanExtra(EXTRA_CREATE, false) ->
|
getBooleanExtra(EXTRA_CREATE, false) ->
|
||||||
WidgetNavRequest.Create(getStringExtra(EXTRA_DATE_ISO))
|
WidgetNavRequest.Create(getStringExtra(EXTRA_DATE_ISO))
|
||||||
getStringExtra(EXTRA_DATE_ISO) != null ->
|
// A widget event tap carries both the occurrence and its source view;
|
||||||
WidgetNavRequest.OpenDate(getStringExtra(EXTRA_DATE_ISO)!!)
|
// 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
|
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? {
|
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)
|
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
|
||||||
if (eventId == -1L) return null
|
if (eventId == -1L) return null
|
||||||
return longArrayOf(
|
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_DATE_ISO = "de.jeanlucmakiola.calendula.extra.DATE_ISO"
|
||||||
private const val EXTRA_CREATE = "de.jeanlucmakiola.calendula.extra.CREATE"
|
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/
|
// Fired by the launcher long-press "New event" shortcut (res/xml/
|
||||||
// shortcuts.xml hardcodes this string — keep the two in sync).
|
// shortcuts.xml hardcodes this string — keep the two in sync).
|
||||||
const val ACTION_NEW_EVENT = "de.jeanlucmakiola.calendula.action.NEW_EVENT"
|
const val ACTION_NEW_EVENT = "de.jeanlucmakiola.calendula.action.NEW_EVENT"
|
||||||
@@ -190,14 +215,35 @@ class MainActivity : AppCompatActivity() {
|
|||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
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 {
|
Intent(context, MainActivity::class.java).apply {
|
||||||
data = "calendula://date/$date".toUri()
|
data = "calendula://date/$date".toUri()
|
||||||
putExtra(EXTRA_DATE_ISO, date.toString())
|
putExtra(EXTRA_DATE_ISO, date.toString())
|
||||||
|
putExtra(EXTRA_SOURCE_VIEW, source.name)
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
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). */
|
/** Open the create-event form prefilled for [date] (home-screen widgets). */
|
||||||
fun openCreateIntent(context: Context, date: LocalDate): Intent =
|
fun openCreateIntent(context: Context, date: LocalDate): Intent =
|
||||||
Intent(context, MainActivity::class.java).apply {
|
Intent(context, MainActivity::class.java).apply {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.edit
|
|||||||
import androidx.datastore.preferences.core.intPreferencesKey
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
@@ -69,6 +70,20 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[WEEK_START_KEY] = pref.name }
|
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
|
* Optional event-form fields shown by default (the rest hide behind
|
||||||
* "more fields"). Stored comma-joined by enum name: an absent key means
|
* "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 THEME_MODE_KEY = stringPreferencesKey("theme_mode")
|
||||||
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
||||||
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
|
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 FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
||||||
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||||
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui
|
package de.jeanlucmakiola.calendula.ui
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
@@ -12,15 +14,22 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.listSaver
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
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.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.calendarFadeThrough
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
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.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.day.DayScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
||||||
@@ -35,14 +44,24 @@ import kotlinx.datetime.toLocalDateTime
|
|||||||
import kotlin.time.Clock
|
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
|
* screens. Each screen owns its own ViewModel and date anchor; the view-switcher
|
||||||
* pill in their top bars writes back here via [onSelectView].
|
* 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
|
* [requestedDetailKey] is an externally requested occurrence (a tapped
|
||||||
* reminder notification routed through MainActivity): it opens the detail
|
* reminder notification routed through MainActivity): it opens the detail
|
||||||
* overlay exactly like an event tap and is cleared via [onDetailKeyConsumed]
|
* 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
|
@Composable
|
||||||
fun CalendarHost(
|
fun CalendarHost(
|
||||||
@@ -53,15 +72,24 @@ fun CalendarHost(
|
|||||||
onWidgetNavConsumed: () -> Unit = {},
|
onWidgetNavConsumed: () -> Unit = {},
|
||||||
requestedImportUri: android.net.Uri? = null,
|
requestedImportUri: android.net.Uri? = null,
|
||||||
onImportConsumed: () -> Unit = {},
|
onImportConsumed: () -> Unit = {},
|
||||||
|
viewModel: CalendarHostViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
var view by rememberSaveable { mutableStateOf(CalendarView.Week) }
|
// Wait for the persisted default view before seeding the stack, so the app
|
||||||
val onSelectView: (CalendarView) -> Unit = { view = it }
|
// 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.
|
// Tapping a day in the month grid opens the day view anchored to that date.
|
||||||
var pendingDayIso by rememberSaveable { mutableStateOf<String?>(null) }
|
var pendingDayIso by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
val onOpenDay: (LocalDate) -> Unit = { date ->
|
val onOpenDay: (LocalDate) -> Unit = { date ->
|
||||||
pendingDayIso = date.toString()
|
pendingDayIso = date.toString()
|
||||||
view = CalendarView.Day
|
viewStack = viewStack.drillToDay()
|
||||||
}
|
}
|
||||||
|
|
||||||
// The event-detail screen (S4) is a full-screen destination hoisted here so
|
// The event-detail screen (S4) is a full-screen destination hoisted here so
|
||||||
@@ -156,17 +184,30 @@ fun CalendarHost(
|
|||||||
importForm = null
|
importForm = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// A home-screen widget launch asks to open a date (→ day view) or start a
|
// A home-screen widget launch asks to open a date (→ day view), open an
|
||||||
// create. Handled once and cleared, mirroring [requestedDetailKey].
|
// 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) {
|
LaunchedEffect(widgetNavRequest) {
|
||||||
when (val req = widgetNavRequest) {
|
when (val req = widgetNavRequest) {
|
||||||
is WidgetNavRequest.OpenDate -> {
|
is WidgetNavRequest.OpenDate -> {
|
||||||
// Reveal the day view: drop any overlay that would cover it, so
|
// Drill the day view in over the widget's view: drop any overlay
|
||||||
// an external date-open doesn't land under an open Settings/form.
|
// that would cover it, so the open doesn't land under Settings/form.
|
||||||
dismissCoveringOverlays()
|
dismissCoveringOverlays()
|
||||||
createDateIso = null
|
createDateIso = null
|
||||||
pendingDayIso = req.dateIso
|
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()
|
onWidgetNavConsumed()
|
||||||
}
|
}
|
||||||
is WidgetNavRequest.Create -> {
|
is WidgetNavRequest.Create -> {
|
||||||
@@ -189,10 +230,29 @@ fun CalendarHost(
|
|||||||
|
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
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()) {
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
when (view) {
|
// Switching between the peer views (month/week/day/agenda) is lateral
|
||||||
|
// navigation, so it fades through rather than sliding — paging *within* a
|
||||||
|
// view keeps the directional slide. AnimatedContent keyed on the view type.
|
||||||
|
val viewSwitch = calendarFadeThrough()
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = view,
|
||||||
|
transitionSpec = { viewSwitch },
|
||||||
|
label = "view-switch",
|
||||||
|
) { currentView ->
|
||||||
|
when (currentView) {
|
||||||
CalendarView.Week -> WeekScreen(
|
CalendarView.Week -> WeekScreen(
|
||||||
selectedView = view,
|
selectedView = currentView,
|
||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
@@ -200,7 +260,7 @@ fun CalendarHost(
|
|||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
)
|
)
|
||||||
CalendarView.Day -> DayScreen(
|
CalendarView.Day -> DayScreen(
|
||||||
selectedView = view,
|
selectedView = currentView,
|
||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
@@ -209,7 +269,7 @@ fun CalendarHost(
|
|||||||
initialDateIso = pendingDayIso,
|
initialDateIso = pendingDayIso,
|
||||||
)
|
)
|
||||||
CalendarView.Month -> MonthScreen(
|
CalendarView.Month -> MonthScreen(
|
||||||
selectedView = view,
|
selectedView = currentView,
|
||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
@@ -217,7 +277,7 @@ fun CalendarHost(
|
|||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
)
|
)
|
||||||
CalendarView.Agenda -> AgendaScreen(
|
CalendarView.Agenda -> AgendaScreen(
|
||||||
selectedView = view,
|
selectedView = currentView,
|
||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
@@ -225,6 +285,7 @@ fun CalendarHost(
|
|||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Search overlay — below detail/edit in the Box so a tapped result's
|
// Search overlay — below detail/edit in the Box so a tapped result's
|
||||||
// detail screen draws on top, and closing it returns to the results.
|
// detail screen draws on top, and closing it returns to the results.
|
||||||
@@ -338,3 +399,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] } },
|
||||||
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@ package de.jeanlucmakiola.calendula.ui
|
|||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.compose.animation.Crossfade
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -20,6 +23,7 @@ import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
|
|||||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
|
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun RootScreen(
|
fun RootScreen(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -51,13 +55,19 @@ fun RootScreen(
|
|||||||
onDispose { lifecycle.removeObserver(obs) }
|
onDispose { lifecycle.removeObserver(obs) }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasPermission) {
|
// Cross-fade the one-time onboarding gates so granting permission / finishing
|
||||||
|
// onboarding eases into the next screen instead of snapping. A fade carries no
|
||||||
|
// spatial motion, so it stays appropriate under "remove animations" too.
|
||||||
|
val gateSpec = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
|
||||||
|
Crossfade(targetState = hasPermission, animationSpec = gateSpec, label = "permissionGate") { granted ->
|
||||||
|
if (granted) {
|
||||||
// Second onboarding gate (v1.4, one-time): reminder notifications.
|
// Second onboarding gate (v1.4, one-time): reminder notifications.
|
||||||
// Null until DataStore's first emission — render nothing for that
|
// Null until DataStore's first emission — render nothing for that
|
||||||
// frame instead of flashing the wrong screen.
|
// frame instead of flashing the wrong screen.
|
||||||
val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
|
val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
|
||||||
val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle()
|
val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle()
|
||||||
when (onboardingDone) {
|
Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done ->
|
||||||
|
when (done) {
|
||||||
true -> CalendarHost(
|
true -> CalendarHost(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
requestedDetailKey = requestedDetailKey,
|
requestedDetailKey = requestedDetailKey,
|
||||||
@@ -73,6 +83,7 @@ fun RootScreen(
|
|||||||
)
|
)
|
||||||
null -> {}
|
null -> {}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
PermissionScreen(
|
PermissionScreen(
|
||||||
onGranted = { hasPermission = true },
|
onGranted = { hasPermission = true },
|
||||||
@@ -80,3 +91,4 @@ fun RootScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,28 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui
|
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.
|
* A navigation a home-screen widget asked the app to perform when launched.
|
||||||
* Parsed from the launch intent in MainActivity and consumed once by
|
* Parsed from the launch intent in MainActivity and consumed once by
|
||||||
* [CalendarHost] (event taps reuse the existing reminder detail-key channel, so
|
* [CalendarHost]. Every request carries the [source] view of the widget it came
|
||||||
* they are not modelled here).
|
* 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 {
|
sealed interface WidgetNavRequest {
|
||||||
/** Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date). */
|
/** Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date), over [source]. */
|
||||||
data class OpenDate(val dateIso: String) : WidgetNavRequest
|
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). */
|
/** Open the create-event form prefilled for [dateIso] (today when null). */
|
||||||
data class Create(val dateIso: String?) : WidgetNavRequest
|
data class Create(val dateIso: String?) : WidgetNavRequest
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
@@ -190,6 +191,7 @@ private fun AgendaList(
|
|||||||
AgendaEventRow(
|
AgendaEventRow(
|
||||||
event = event,
|
event = event,
|
||||||
position = positionOf(index, day.events.size),
|
position = positionOf(index, day.events.size),
|
||||||
|
modifier = calendarAnimateItem(),
|
||||||
onClick = { onEventClick(event) },
|
onClick = { onEventClick(event) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -221,11 +223,13 @@ private fun AgendaDayHeader(date: LocalDate, today: LocalDate) {
|
|||||||
private fun AgendaEventRow(
|
private fun AgendaEventRow(
|
||||||
event: EventInstance,
|
event: EventInstance,
|
||||||
position: Position,
|
position: Position,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
|
modifier = modifier,
|
||||||
title = title,
|
title = title,
|
||||||
summary = agendaTimeSummary(event),
|
summary = agendaTimeSummary(event),
|
||||||
position = position,
|
position = position,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import android.accounts.AccountManager
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -73,6 +72,7 @@ import de.jeanlucmakiola.calendula.R
|
|||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||||
@@ -295,10 +295,9 @@ private fun CalendarEditor(
|
|||||||
var confirmDelete by remember { mutableStateOf(false) }
|
var confirmDelete by remember { mutableStateOf(false) }
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
|
|
||||||
BackHandler(onBack = onClose)
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
.predictiveBack(onBack = onClose)
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(MaterialTheme.colorScheme.surface),
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
topBar = {
|
topBar = {
|
||||||
|
|||||||
@@ -1,14 +1,56 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.activity.BackEventCompat
|
||||||
|
import androidx.activity.compose.PredictiveBackHandler
|
||||||
import androidx.compose.animation.ContentTransform
|
import androidx.compose.animation.ContentTransform
|
||||||
|
import androidx.compose.animation.EnterTransition
|
||||||
|
import androidx.compose.animation.ExitTransition
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
import androidx.compose.animation.slideInHorizontally
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
|
import androidx.compose.foundation.lazy.LazyItemScope
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the user has asked the system to remove animations (Settings →
|
||||||
|
* Accessibility → "Remove animations", which sets the global animator duration
|
||||||
|
* scale to 0). Compose animations do *not* honour this platform flag on their
|
||||||
|
* own, so the shared motion helpers in this file check it and fall back to a
|
||||||
|
* quick cross-fade — opacity only, no spatial movement — to respect the
|
||||||
|
* vestibular intent of the setting while keeping state changes legible.
|
||||||
|
*
|
||||||
|
* Read once at composition; the scale changes rarely and only takes full effect
|
||||||
|
* after a process restart anyway.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberReduceMotion(): Boolean {
|
||||||
|
val resolver = LocalContext.current.contentResolver
|
||||||
|
return remember(resolver) {
|
||||||
|
Settings.Global.getFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The M3 Expressive spatial spring used for the month/week slide: the *fast*
|
* The M3 Expressive spatial spring used for the month/week slide: the *fast*
|
||||||
@@ -24,17 +66,160 @@ fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
|
|||||||
MaterialTheme.motionScheme.fastSpatialSpec()
|
MaterialTheme.motionScheme.fastSpatialSpec()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Horizontal slide for navigating between adjacent months/weeks.
|
* The fast effects spec from the active motion scheme, for opacity (fade)
|
||||||
|
* transitions. Captured in composable scope alongside [rememberCalendarSlideSpec]
|
||||||
|
* for use in non-composable transition lambdas and as the reduced-motion fallback.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> =
|
||||||
|
MaterialTheme.motionScheme.fastEffectsSpec()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Horizontal slide for navigating between adjacent months/weeks/days.
|
||||||
*
|
*
|
||||||
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
|
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
|
||||||
* (e.g. "today"); a jump reuses the forward direction.
|
* (e.g. "today"); a jump reuses the forward direction.
|
||||||
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
|
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
|
||||||
|
* @param fadeSpec effects spec for the reduced-motion fade, typically
|
||||||
|
* [rememberCalendarFadeSpec].
|
||||||
|
* @param reduceMotion when true, swap the directional slide for a plain cross-fade.
|
||||||
*/
|
*/
|
||||||
fun calendarSlideTransition(
|
fun calendarSlideTransition(
|
||||||
slideDir: Int,
|
slideDir: Int,
|
||||||
spec: FiniteAnimationSpec<IntOffset>,
|
spec: FiniteAnimationSpec<IntOffset>,
|
||||||
|
fadeSpec: FiniteAnimationSpec<Float>,
|
||||||
|
reduceMotion: Boolean,
|
||||||
): ContentTransform {
|
): ContentTransform {
|
||||||
|
if (reduceMotion) {
|
||||||
|
return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
|
||||||
|
}
|
||||||
val dir = if (slideDir == 0) 1 else slideDir
|
val dir = if (slideDir == 0) 1 else slideDir
|
||||||
return slideInHorizontally(spec) { w -> dir * w }
|
return slideInHorizontally(spec) { w -> dir * w }
|
||||||
.togetherWith(slideOutHorizontally(spec) { w -> -dir * w })
|
.togetherWith(slideOutHorizontally(spec) { w -> -dir * w })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-fade [ContentTransform] for swapping whole screens or content blocks
|
||||||
|
* where there is no meaningful spatial direction (e.g. onboarding gates). Pure
|
||||||
|
* opacity, so it doubles as its own reduced-motion form.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun calendarFadeThrough(): ContentTransform {
|
||||||
|
val fade = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
|
||||||
|
return fadeIn(fade).togetherWith(fadeOut(fade))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enter transition for a vertically-revealed section (expandable rows, inline
|
||||||
|
* fields): height grows from the top while fading in. Under reduced motion the
|
||||||
|
* height growth is dropped, leaving a quick fade.
|
||||||
|
*
|
||||||
|
* Pair with [calendarCollapseExit]. This is the promoted form of the pattern
|
||||||
|
* originally inlined in the event edit form, so every expandable surface in the
|
||||||
|
* app reveals the same way.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun calendarExpandEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition {
|
||||||
|
val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec())
|
||||||
|
return if (reduceMotion) {
|
||||||
|
fade
|
||||||
|
} else {
|
||||||
|
expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exit counterpart to [calendarExpandEnter]: shrink + fade, or fade only under reduced motion. */
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun calendarCollapseExit(reduceMotion: Boolean = rememberReduceMotion()): ExitTransition {
|
||||||
|
val fade = fadeOut(MaterialTheme.motionScheme.fastEffectsSpec())
|
||||||
|
return if (reduceMotion) {
|
||||||
|
fade
|
||||||
|
} else {
|
||||||
|
shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enter transition for content revealed by an `AnimatedContent`/`AnimatedVisibility`
|
||||||
|
* (e.g. search results once a query resolves): a gentle rise + fade. Reduced
|
||||||
|
* motion keeps the fade only.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun calendarItemEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition {
|
||||||
|
val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec())
|
||||||
|
return if (reduceMotion) {
|
||||||
|
fade
|
||||||
|
} else {
|
||||||
|
fade + slideInVertically(MaterialTheme.motionScheme.fastSpatialSpec()) { h -> h / 6 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared [LazyItemScope.animateItem] wiring so list rows fade/relocate with the
|
||||||
|
* app's motion scheme instead of Compose's default spring. Returns a bare
|
||||||
|
* [Modifier] under reduced motion so rows snap into place. Requires the list to
|
||||||
|
* supply stable item keys.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun LazyItemScope.calendarAnimateItem(reduceMotion: Boolean = rememberReduceMotion()): Modifier =
|
||||||
|
if (reduceMotion) {
|
||||||
|
Modifier
|
||||||
|
} else {
|
||||||
|
Modifier.animateItem(
|
||||||
|
fadeInSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
|
||||||
|
placementSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
|
||||||
|
fadeOutSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard Android predictive-back transform for a full-screen overlay: as the
|
||||||
|
* back gesture is dragged, the surface scales toward ~90%, shifts toward the
|
||||||
|
* swiped edge and rounds its corners, previewing what's behind. Completing the
|
||||||
|
* gesture invokes [onBack]; cancelling springs it back.
|
||||||
|
*
|
||||||
|
* A drop-in replacement for a screen's own `BackHandler(onBack)` — register it
|
||||||
|
* once and apply the returned [Modifier] to that screen's root so the preview
|
||||||
|
* respects the same back semantics. Under reduced motion the visual preview is
|
||||||
|
* skipped (the back still works); on API < 34 the system delivers no progress,
|
||||||
|
* so it degrades to a plain back.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun Modifier.predictiveBack(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
reduceMotion: Boolean = rememberReduceMotion(),
|
||||||
|
): Modifier {
|
||||||
|
val progress = remember { Animatable(0f) }
|
||||||
|
var fromLeftEdge by remember { mutableStateOf(true) }
|
||||||
|
|
||||||
|
PredictiveBackHandler(enabled = enabled) { events ->
|
||||||
|
try {
|
||||||
|
events.collect { event ->
|
||||||
|
fromLeftEdge = event.swipeEdge == BackEventCompat.EDGE_LEFT
|
||||||
|
progress.snapTo(FastOutSlowInEasing.transform(event.progress))
|
||||||
|
}
|
||||||
|
onBack()
|
||||||
|
progress.snapTo(0f)
|
||||||
|
} catch (_: CancellationException) {
|
||||||
|
progress.animateTo(0f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reduceMotion) return this
|
||||||
|
return this.graphicsLayer {
|
||||||
|
val p = progress.value
|
||||||
|
val scale = 1f - 0.1f * p
|
||||||
|
scaleX = scale
|
||||||
|
scaleY = scale
|
||||||
|
translationX = (if (fromLeftEdge) 1f else -1f) * 24.dp.toPx() * p
|
||||||
|
shape = RoundedCornerShape(32.dp.toPx() * p)
|
||||||
|
clip = p > 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -49,3 +49,31 @@ fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): Calend
|
|||||||
if (i < 0) return available.firstOrNull() ?: CalendarView.Month
|
if (i < 0) return available.firstOrNull() ?: CalendarView.Month
|
||||||
return available[(i + 1) % available.size]
|
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
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
@@ -73,11 +72,11 @@ fun CollapsingScaffold(
|
|||||||
actions: @Composable RowScope.() -> Unit = {},
|
actions: @Composable RowScope.() -> Unit = {},
|
||||||
content: @Composable ColumnScope.() -> Unit,
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
BackHandler(onBack = onBack)
|
|
||||||
val scrollBehavior =
|
val scrollBehavior =
|
||||||
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
.predictiveBack(onBack = onBack)
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
|
|||||||
@@ -173,7 +173,11 @@ fun ReminderDefaultPicker(
|
|||||||
},
|
},
|
||||||
onClick = { customExpanded = !customExpanded },
|
onClick = { customExpanded = !customExpanded },
|
||||||
)
|
)
|
||||||
AnimatedVisibility(visible = customExpanded) {
|
AnimatedVisibility(
|
||||||
|
visible = customExpanded,
|
||||||
|
enter = calendarExpandEnter(),
|
||||||
|
exit = calendarCollapseExit(),
|
||||||
|
) {
|
||||||
CustomReminderEditor(
|
CustomReminderEditor(
|
||||||
amountText = amountText,
|
amountText = amountText,
|
||||||
onAmountChange = { amountText = it },
|
onAmountChange = { amountText = it },
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
@@ -237,6 +239,8 @@ private fun DayContent(
|
|||||||
val threshold = with(density) { 24.dp.toPx() }
|
val threshold = with(density) { 24.dp.toPx() }
|
||||||
var dragAccum by remember { mutableFloatStateOf(0f) }
|
var dragAccum by remember { mutableFloatStateOf(0f) }
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
|
val fadeSpec = rememberCalendarFadeSpec()
|
||||||
|
val reduceMotion = rememberReduceMotion()
|
||||||
|
|
||||||
// Hoisted above the per-day AnimatedContent so the vertical scroll position
|
// Hoisted above the per-day AnimatedContent so the vertical scroll position
|
||||||
// survives day-to-day swipes. We only centre on noon once, on first entry
|
// survives day-to-day swipes. We only centre on noon once, on first entry
|
||||||
@@ -287,7 +291,7 @@ private fun DayContent(
|
|||||||
DayUiState.Loading -> "loading"
|
DayUiState.Loading -> "loading"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
|
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
||||||
label = "day-transition",
|
label = "day-transition",
|
||||||
) { s ->
|
) { s ->
|
||||||
when (s) {
|
when (s) {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -92,6 +91,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
@@ -132,8 +132,6 @@ fun EventDetailScreen(
|
|||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val deleteState by viewModel.deleteState.collectAsStateWithLifecycle()
|
val deleteState by viewModel.deleteState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
BackHandler(onBack = onBack)
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
@@ -214,6 +212,7 @@ fun EventDetailScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
modifier = Modifier.predictiveBack(onBack = onBack),
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
|
|||||||
@@ -8,14 +8,9 @@ import android.content.pm.PackageManager
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.provider.ContactsContract
|
import android.provider.ContactsContract
|
||||||
import android.util.Patterns
|
import android.util.Patterns
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.expandVertically
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.shrinkVertically
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
@@ -63,7 +58,6 @@ import androidx.compose.material3.DropdownMenu
|
|||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -121,6 +115,9 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
|||||||
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
||||||
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
|
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
|
||||||
import de.jeanlucmakiola.calendula.domain.toRRule
|
import de.jeanlucmakiola.calendula.domain.toRRule
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||||
@@ -202,8 +199,6 @@ fun EventEditScreen(
|
|||||||
viewModel.reset()
|
viewModel.reset()
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
BackHandler(onBack = close)
|
|
||||||
|
|
||||||
// The event vanished between the detail screen and the edit tap — fall
|
// The event vanished between the detail screen and the edit tap — fall
|
||||||
// back to the detail screen, which shows its own failure state.
|
// back to the detail screen, which shows its own failure state.
|
||||||
LaunchedEffect(loadFailed) {
|
LaunchedEffect(loadFailed) {
|
||||||
@@ -253,6 +248,7 @@ fun EventEditScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
modifier = Modifier.predictiveBack(onBack = close),
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -410,7 +406,6 @@ private enum class PickerTarget { StartDate, StartTime, EndDate, EndTime }
|
|||||||
* spring open with the expressive spatial spec instead of popping in.
|
* spring open with the expressive spatial spec instead of popping in.
|
||||||
* (Initially-visible sections render without animating.)
|
* (Initially-visible sections render without animating.)
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun OptionalFormSection(
|
private fun OptionalFormSection(
|
||||||
visible: Boolean,
|
visible: Boolean,
|
||||||
@@ -418,10 +413,8 @@ private fun OptionalFormSection(
|
|||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter = expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) +
|
enter = calendarExpandEnter(),
|
||||||
fadeIn(MaterialTheme.motionScheme.fastEffectsSpec()),
|
exit = calendarCollapseExit(),
|
||||||
exit = shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) +
|
|
||||||
fadeOut(MaterialTheme.motionScheme.fastEffectsSpec()),
|
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxWidth(), content = content)
|
Column(modifier = Modifier.fillMaxWidth(), content = content)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.imports
|
package de.jeanlucmakiola.calendula.ui.imports
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -39,6 +38,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
|
import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
||||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,7 +57,6 @@ fun ImportScreen(
|
|||||||
) {
|
) {
|
||||||
LaunchedEffect(uri) { viewModel.load(uri) }
|
LaunchedEffect(uri) { viewModel.load(uri) }
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
BackHandler(onBack = onClose)
|
|
||||||
|
|
||||||
// A single event isn't shown here — it opens the create form for review.
|
// A single event isn't shown here — it opens the create form for review.
|
||||||
LaunchedEffect(state) {
|
LaunchedEffect(state) {
|
||||||
@@ -65,7 +64,9 @@ fun ImportScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier
|
||||||
|
.predictiveBack(onBack = onClose)
|
||||||
|
.fillMaxSize(),
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(stringResource(R.string.import_title)) },
|
title = { Text(stringResource(R.string.import_title)) },
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
@@ -217,6 +219,8 @@ private fun MonthContent(
|
|||||||
val threshold = with(density) { 6.dp.toPx() }
|
val threshold = with(density) { 6.dp.toPx() }
|
||||||
var dragAccum by remember { mutableFloatStateOf(0f) }
|
var dragAccum by remember { mutableFloatStateOf(0f) }
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
|
val fadeSpec = rememberCalendarFadeSpec()
|
||||||
|
val reduceMotion = rememberReduceMotion()
|
||||||
|
|
||||||
val swipeModifier = Modifier.pointerInput(Unit) {
|
val swipeModifier = Modifier.pointerInput(Unit) {
|
||||||
detectHorizontalDragGestures(
|
detectHorizontalDragGestures(
|
||||||
@@ -243,7 +247,7 @@ private fun MonthContent(
|
|||||||
MonthUiState.Loading -> "loading"
|
MonthUiState.Loading -> "loading"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
|
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
||||||
label = "month-transition",
|
label = "month-transition",
|
||||||
) { s ->
|
) { s ->
|
||||||
when (s) {
|
when (s) {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.search
|
package de.jeanlucmakiola.calendula.ui.search
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -49,6 +48,8 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
||||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||||
@@ -88,10 +89,8 @@ fun SearchScreen(
|
|||||||
focusRequester.requestFocus()
|
focusRequester.requestFocus()
|
||||||
keyboard?.show()
|
keyboard?.show()
|
||||||
}
|
}
|
||||||
BackHandler(onBack = onBack)
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier,
|
modifier = modifier.predictiveBack(onBack = onBack),
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -174,6 +173,7 @@ private fun SearchResults(
|
|||||||
SearchResultRow(
|
SearchResultRow(
|
||||||
event = event,
|
event = event,
|
||||||
position = positionOf(index, events.size),
|
position = positionOf(index, events.size),
|
||||||
|
modifier = calendarAnimateItem(),
|
||||||
onClick = { onEventClick(event) },
|
onClick = { onEventClick(event) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -184,10 +184,12 @@ private fun SearchResults(
|
|||||||
private fun SearchResultRow(
|
private fun SearchResultRow(
|
||||||
event: EventInstance,
|
event: EventInstance,
|
||||||
position: Position,
|
position: Position,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
|
modifier = modifier,
|
||||||
title = event.title,
|
title = event.title,
|
||||||
summary = searchSummary(event),
|
summary = searchSummary(event),
|
||||||
position = position,
|
position = position,
|
||||||
|
|||||||
@@ -88,8 +88,12 @@ import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
|||||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker
|
import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
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.CalendarColorChip
|
||||||
import de.jeanlucmakiola.calendula.ui.common.OptionPicker
|
import de.jeanlucmakiola.calendula.ui.common.OptionPicker
|
||||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||||
@@ -404,6 +408,7 @@ private fun AppearanceScreen(
|
|||||||
) {
|
) {
|
||||||
var showTheme by remember { mutableStateOf(false) }
|
var showTheme by remember { mutableStateOf(false) }
|
||||||
var showWeekStart by remember { mutableStateOf(false) }
|
var showWeekStart by remember { mutableStateOf(false) }
|
||||||
|
var showDefaultView by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_appearance),
|
title = stringResource(R.string.settings_section_appearance),
|
||||||
@@ -415,6 +420,12 @@ private fun AppearanceScreen(
|
|||||||
position = Position.Top,
|
position = Position.Top,
|
||||||
onClick = { showTheme = true },
|
onClick = { showTheme = true },
|
||||||
)
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_default_view),
|
||||||
|
summary = stringResource(state.defaultView.labelRes),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { showDefaultView = true },
|
||||||
|
)
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_dynamic_color),
|
title = stringResource(R.string.settings_dynamic_color),
|
||||||
summary = if (state.dynamicColorAvailable) {
|
summary = if (state.dynamicColorAvailable) {
|
||||||
@@ -464,6 +475,16 @@ private fun AppearanceScreen(
|
|||||||
onDismiss = { showWeekStart = false },
|
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
|
@Composable
|
||||||
@@ -655,7 +676,11 @@ private fun NotificationsScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
AnimatedVisibility(visible = expanded) {
|
AnimatedVisibility(
|
||||||
|
visible = expanded,
|
||||||
|
enter = calendarExpandEnter(),
|
||||||
|
exit = calendarCollapseExit(),
|
||||||
|
) {
|
||||||
Column {
|
Column {
|
||||||
val timed = state.perCalendarReminderOverride.choiceFor(calendar.id)
|
val timed = state.perCalendarReminderOverride.choiceFor(calendar.id)
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings screen state (M4). Persisted preferences are instant to read, so
|
* Settings screen state (M4). Persisted preferences are instant to read, so
|
||||||
@@ -17,6 +18,8 @@ data class SettingsUiState(
|
|||||||
val dynamicColor: Boolean = true,
|
val dynamicColor: Boolean = true,
|
||||||
val dynamicColorAvailable: Boolean = true,
|
val dynamicColorAvailable: Boolean = true,
|
||||||
val weekStart: WeekStartPref = WeekStartPref.AUTO,
|
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"). */
|
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
||||||
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
||||||
/** Whether Calendula posts reminder notifications (v1.4). */
|
/** Whether Calendula posts reminder notifications (v1.4). */
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -72,8 +73,10 @@ class SettingsViewModel @Inject constructor(
|
|||||||
) { overrides, allDayOverrides, calendars ->
|
) { overrides, allDayOverrides, calendars ->
|
||||||
ReminderOverrides(overrides, allDayOverrides, calendars)
|
ReminderOverrides(overrides, allDayOverrides, calendars)
|
||||||
},
|
},
|
||||||
) { base, defaults, overrides ->
|
prefs.defaultView,
|
||||||
|
) { base, defaults, overrides, defaultView ->
|
||||||
base.copy(
|
base.copy(
|
||||||
|
defaultView = defaultView,
|
||||||
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
||||||
defaultReminderMinutes = defaults.defaultReminder,
|
defaultReminderMinutes = defaults.defaultReminder,
|
||||||
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||||
@@ -115,6 +118,10 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setWeekStart(pref) }
|
viewModelScope.launch { prefs.setWeekStart(pref) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setDefaultView(view: CalendarView) {
|
||||||
|
viewModelScope.launch { prefs.setDefaultView(view) }
|
||||||
|
}
|
||||||
|
|
||||||
fun setFormFieldDefault(field: EventFormField, enabled: Boolean) {
|
fun setFormFieldDefault(field: EventFormField, enabled: Boolean) {
|
||||||
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
|
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
@@ -247,6 +249,8 @@ private fun WeekContent(
|
|||||||
val threshold = with(density) { 24.dp.toPx() }
|
val threshold = with(density) { 24.dp.toPx() }
|
||||||
var dragAccum by remember { mutableFloatStateOf(0f) }
|
var dragAccum by remember { mutableFloatStateOf(0f) }
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
|
val fadeSpec = rememberCalendarFadeSpec()
|
||||||
|
val reduceMotion = rememberReduceMotion()
|
||||||
|
|
||||||
// Hoisted above the per-week AnimatedContent so the vertical scroll position
|
// Hoisted above the per-week AnimatedContent so the vertical scroll position
|
||||||
// survives week-to-week swipes (e.g. 18:00 stays centred). We only centre on
|
// survives week-to-week swipes (e.g. 18:00 stays centred). We only centre on
|
||||||
@@ -300,7 +304,7 @@ private fun WeekContent(
|
|||||||
WeekUiState.Loading -> "loading"
|
WeekUiState.Loading -> "loading"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
|
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
||||||
label = "week-transition",
|
label = "week-transition",
|
||||||
) { s ->
|
) { s ->
|
||||||
when (s) {
|
when (s) {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import androidx.glance.text.TextStyle
|
|||||||
import de.jeanlucmakiola.calendula.MainActivity
|
import de.jeanlucmakiola.calendula.MainActivity
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
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.common.pastelize
|
||||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||||
@@ -185,7 +186,11 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
|
|||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(start = 8.dp, end = 8.dp, top = 10.dp, bottom = 4.dp)
|
.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)
|
.padding(horizontal = 4.dp, vertical = 4.dp)
|
||||||
.clickable(
|
.clickable(
|
||||||
actionStartActivity(
|
actionStartActivity(
|
||||||
MainActivity.eventDetailIntent(
|
MainActivity.openEventIntent(
|
||||||
context = context,
|
context = context,
|
||||||
eventId = event.eventId,
|
eventId = event.eventId,
|
||||||
beginMillis = event.start.toEpochMilliseconds(),
|
beginMillis = event.start.toEpochMilliseconds(),
|
||||||
endMillis = event.end.toEpochMilliseconds(),
|
endMillis = event.end.toEpochMilliseconds(),
|
||||||
|
source = CalendarView.Agenda,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import androidx.glance.unit.ColorProvider
|
|||||||
import de.jeanlucmakiola.calendula.MainActivity
|
import de.jeanlucmakiola.calendula.MainActivity
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
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.common.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
|
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
|
||||||
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
|
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
|
||||||
@@ -209,7 +210,9 @@ private fun MonthHeader(label: String) {
|
|||||||
resId = R.drawable.ic_widget_today,
|
resId = R.drawable.ic_widget_today,
|
||||||
contentDescription = context.getString(R.string.widget_today),
|
contentDescription = context.getString(R.string.widget_today),
|
||||||
onClick = GlanceModifier.clickable(
|
onClick = GlanceModifier.clickable(
|
||||||
actionStartActivity(MainActivity.openDateIntent(context, today(systemZone()))),
|
actionStartActivity(
|
||||||
|
MainActivity.openDateIntent(context, today(systemZone()), CalendarView.Month),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
HeaderIcon(
|
HeaderIcon(
|
||||||
@@ -300,8 +303,16 @@ private fun WeekRow(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
|
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
|
||||||
|
val context = LocalContext.current
|
||||||
Box(
|
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,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
@@ -353,7 +364,24 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) {
|
private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) {
|
||||||
val context = LocalContext.current
|
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(
|
Box(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|||||||
@@ -264,6 +264,7 @@
|
|||||||
<string name="settings_theme_system">System</string>
|
<string name="settings_theme_system">System</string>
|
||||||
<string name="settings_theme_light">Light</string>
|
<string name="settings_theme_light">Light</string>
|
||||||
<string name="settings_theme_dark">Dark</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">Dynamic colour</string>
|
||||||
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string>
|
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string>
|
||||||
<string name="settings_week_start">Week starts on</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">App language</string>
|
||||||
<string name="settings_language_auto">System default</string>
|
<string name="settings_language_auto">System default</string>
|
||||||
<!-- Hub category subtitles -->
|
<!-- 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_event_form_subtitle">Default fields for new events</string>
|
||||||
<string name="settings_notifications_subtitle">Event reminders</string>
|
<string name="settings_notifications_subtitle">Event reminders</string>
|
||||||
<string name="settings_section_about">About</string>
|
<string name="settings_section_about">About</string>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
33
fastlane/metadata/android/en-US/changelogs/20900.txt
Normal file
33
fastlane/metadata/android/en-US/changelogs/20900.txt
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
### 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]).
|
||||||
|
- Predictive back. Swiping back from a screen — an event, the editor, search or
|
||||||
|
settings — now follows your finger, shrinking the screen to preview what's
|
||||||
|
behind so you can see where Back will take you before you let go; slide it back
|
||||||
|
to cancel. The preview needs Android 14 or newer; on older versions Back works
|
||||||
|
as before.
|
||||||
|
- Respect for "Remove animations". If you've turned animations off in your
|
||||||
|
device's Accessibility settings, Calendula now honours it everywhere: motion
|
||||||
|
collapses to a quick fade and the back preview is skipped.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Smoother, more consistent motion throughout. Expanding sections, list updates
|
||||||
|
in search and agenda, and the onboarding screens now animate the same way
|
||||||
|
across the app, instead of some places sliding or growing while others popped
|
||||||
|
in. Switching between Month, Week, Day and Agenda now cross-fades rather than
|
||||||
|
snapping, while paging within a view keeps its slide.
|
||||||
|
|
||||||
|
### 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]).
|
||||||
|
|
||||||
Reference in New Issue
Block a user