Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d36d22fd5 | |||
| adcbed6e02 | |||
| efa0abbaed | |||
| d3fbe28843 | |||
| 951fb640a6 | |||
| 94fa206e2e | |||
| 6a90bade8a | |||
| 0132201cf9 | |||
| b792ddc2f0 | |||
| 440fa57161 | |||
|
|
00b5aeaac7 |
@@ -6,15 +6,19 @@
|
||||
|---|---|---|
|
||||
| v0.1 | Foundation & CI | complete |
|
||||
| v0.2 | Data Layer & Permission Flow | complete |
|
||||
| v0.3 | Month view | pending |
|
||||
| v0.4 | Week view | pending |
|
||||
| v0.5 | Day view | pending |
|
||||
| v0.6 | Event Detail Sheet | pending |
|
||||
| v0.7 | Filter & Settings | pending |
|
||||
| v0.3 | Month + Week + Day views, view switcher | complete |
|
||||
| v0.4 | Event Detail (S4) + humanized recurrence | complete |
|
||||
| v0.5 | Calendar filter (M3) + Settings (M4) | complete |
|
||||
| v1.0 | Polish + jump-to-date (M2), F-Droid release | pending |
|
||||
|
||||
Delivery ran ahead of the original table: Day view (S3) shipped in v0.3 and
|
||||
Event Detail (S4) in v0.4, so the Filter/Settings milestone became v0.5.
|
||||
Jump-to-date (M2) was deferred out of v0.5 and folds into the v1.0 polish pass.
|
||||
|
||||
## v1.0 — First Public Release
|
||||
|
||||
All V1 features shipped, polished, on F-Droid. Read-only calendar.
|
||||
Remaining before v1.0: jump-to-date (M2) and a UI polish/QA pass.
|
||||
|
||||
## v2.0 — Write Support
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
# Calendula — Current State
|
||||
|
||||
*Last updated: 2026-06-08*
|
||||
*Last updated: 2026-06-10*
|
||||
|
||||
## Status
|
||||
|
||||
**Milestone:** v0.2 — Data Layer & Permission Flow
|
||||
**Phase:** Plan 02 complete; UI-design iteration pending before Plan 03
|
||||
**Milestone:** v0.5 — Calendar filter (M3) + Settings (M4) (complete)
|
||||
**Phase:** All V1 screens and cross-cutting wiring done except jump-to-date
|
||||
(M2), which is deferred to the v1.0 polish pass
|
||||
|
||||
## Progress
|
||||
|
||||
@@ -13,11 +14,17 @@
|
||||
- [x] V1 design decisions resolved (App name "Calendula", icon, seed color)
|
||||
- [x] Plan 01 written and executed — foundation lands (theme, icon, i18n, Hilt, DataStore, CI green)
|
||||
- [x] Plan 02 written and executed — data layer + permission flow + debug screen
|
||||
- [ ] UI-design iteration (mockups for Month/Week/Day/Detail/Filter/Settings, all three states)
|
||||
- [ ] Plan 03 (Month view)
|
||||
- [x] Month view (S1) — 6-week grid, event dots, today marker, swipe nav, three states (replaces debug screen)
|
||||
- [x] Week view (S2) — time schedule with overlap-resolved lanes, all-day strip, swipe nav, three states
|
||||
- [x] Day view (S3) — single-column slice reusing the week layout
|
||||
- [x] View-switcher (M1) wired — cycles Month ↔ Week ↔ Day
|
||||
- [x] Event-detail screen (S4) — full-screen, humanized recurrence
|
||||
- [x] Filter sheet (M3) — per-calendar visibility, grouped by account, persisted, applied centrally in the repository
|
||||
- [x] Settings (M4) — appearance (theme, dynamic colour, week start), language (per-app locales), about
|
||||
- [ ] Jump-to-date (M2) — drawer entry still stubbed (deferred to v1.0)
|
||||
|
||||
## Next
|
||||
|
||||
1. Iterate on UI design (mockups per screen, all three states)
|
||||
2. Write Plan 03: Month view
|
||||
3. Execute Plan 03 — Debug screen gets replaced by month view
|
||||
1. Jump-to-date (M2) — date picker from the drawer, reachable on every view
|
||||
2. UI polish / QA pass across all views before v1.0
|
||||
3. F-Droid release of v1.0
|
||||
|
||||
85
CHANGELOG.md
85
CHANGELOG.md
@@ -7,6 +7,83 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.5.0] — 2026-06-10
|
||||
|
||||
### Added
|
||||
- Calendar filter (M3): the navigation drawer now hosts the calendar list
|
||||
inline — every calendar grouped by account, each with a colour swatch and a
|
||||
visibility switch. Hiding a calendar is persisted app-side (DataStore,
|
||||
separate from the system VISIBLE flag) and applied centrally in the
|
||||
repository, so month/week/day re-filter live the moment a switch flips.
|
||||
The drawer was trimmed to just Today, the calendar filter, and Settings
|
||||
(the stubbed jump-to-date entry was removed; M2 returns in v1.0)
|
||||
- Settings (M4): a full-screen destination with
|
||||
- **Appearance** — theme (System / Light / Dark), Material You dynamic colour
|
||||
(auto-disabled below Android 12), week start (Automatic / Monday / Sunday)
|
||||
- **Language** — app language (System / Deutsch / English) via per-app
|
||||
locales, persisted across cold starts down to Android 10
|
||||
- **About** — version, license, and a link to the source on Gitea
|
||||
- Week-start preference now drives the month grid and week view; "Automatic"
|
||||
follows the active locale (Monday in DE, Sunday in en-US)
|
||||
|
||||
### Changed
|
||||
- Theme is driven by one activity-scoped settings source, so a theme or
|
||||
dynamic-colour change applies app-wide immediately
|
||||
- `versionName`/`versionCode` bumped to 0.5.0 / 5 (the in-repo version had
|
||||
lagged behind the release tags); the About screen reads it directly
|
||||
|
||||
## [0.4.0] — 2026-06-10
|
||||
|
||||
### Added
|
||||
- Event detail (S4): full-screen destination (MD3 list→detail, not a bottom
|
||||
sheet) opened by tapping an event in the week/day timeline — title with a
|
||||
calendar-colour accent line, a card per field (when, calendar, location,
|
||||
description, attendees, recurrence) with leading icons, location tap opens a
|
||||
maps intent, Loading/Failure/Success states, slide-in/out over the calendar
|
||||
- Human-readable recurrence: RRULE rendered as e.g. "Every week on _Tue_ and
|
||||
_Thu_ until 31 Dec 2026" (FREQ/INTERVAL/BYDAY/UNTIL/COUNT, abbreviated +
|
||||
italicised day names, localized list formatting), with a generic fallback
|
||||
- Month → day navigation: tapping a day cell opens the day view on that date
|
||||
|
||||
### Fixed
|
||||
- Recurring events failed to open in the detail view: the series row stores
|
||||
DURATION instead of DTEND, so the mapper dropped it (EventNotFound). The
|
||||
detail now keeps such events and shows the tapped occurrence's own times
|
||||
(from CalendarContract.Instances) instead of the series start
|
||||
|
||||
## [0.3.0] — 2026-06-10
|
||||
|
||||
### Added
|
||||
- Month view (S1): Material 3 Expressive card-per-day grid (only the current
|
||||
month's weeks; neighbouring days left blank), per-day event dots with "+N"
|
||||
overflow, today emphasised via `primaryContainer`, spring-based press
|
||||
feedback from the active motion scheme, swipe + drawer navigation,
|
||||
Loading/Failure/Success states
|
||||
- Week view (S2): vertical time schedule with overlap-resolved lanes,
|
||||
separate all-day strip, midnight-spanning events clipped per day, swipe
|
||||
navigation, Loading/Failure/Success states
|
||||
- Day view (S3): single-column slice of the week schedule reusing its
|
||||
overlap-lane layout, per-day swipe navigation, noon-centred scroll that
|
||||
persists across swipes, animated all-day strip, compact top bar with the
|
||||
full date, Loading/Failure/Success states
|
||||
- Functional view-switcher (M1) cycling Month ↔ Week ↔ Day
|
||||
- Shared calendar UI building blocks in `ui/common/` (navigation drawer,
|
||||
failure screen, view-switcher pill, color pastelizer, observable locale)
|
||||
|
||||
### Removed
|
||||
- Throwaway debug screen — superseded by the month view
|
||||
|
||||
## [0.2.1] — 2026-06-09
|
||||
|
||||
### Changed
|
||||
- Regenerated the F-Droid catalog `icon.png` (512x512, both locales) so it
|
||||
is pixel-faithful to the on-device adaptive launcher icon: same slate
|
||||
background (`#5C6B7A`), off-white mark (`#FAF6F0`), and the foreground
|
||||
group transform (`scale 0.5`, pivot `114,108`, translate `2,8`) baked in.
|
||||
- Added `design/icon/calendula_launcher.svg` — the composed full-bleed
|
||||
icon (background + transformed mark) as the single source of truth for
|
||||
store/F-Droid renders.
|
||||
|
||||
## [0.2.0] — 2026-06-08
|
||||
|
||||
### Added
|
||||
@@ -19,6 +96,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Unit-Tests für Cursor-Mapping (alle §8-Defensiv-Cases), Repository-Flows mit Turbine, DataStore round-trip
|
||||
- Instrumented smoke test against the real CalendarContract provider
|
||||
|
||||
### Changed
|
||||
- Redesigned launcher icon: line-art calendar with a stylized "1" inside
|
||||
(kalendae reference) and a small calendula bloom badge in the
|
||||
bottom-right corner. Replaces the simple "1"-only foreground from
|
||||
v0.1.0. Source SVG checked in at `design/icon/calendula_mark.svg`,
|
||||
also used to regenerate the F-Droid catalog `icon.png` (512x512)
|
||||
per locale.
|
||||
|
||||
## [0.1.1] — 2026-06-08
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -23,8 +23,8 @@ android {
|
||||
applicationId = "de.jeanlucmakiola.calendula"
|
||||
minSdk = 29
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
versionCode = 5
|
||||
versionName = "0.5.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -89,6 +89,7 @@ kotlin {
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
@@ -98,6 +99,8 @@ dependencies {
|
||||
implementation(libs.androidx.ui.graphics)
|
||||
implementation(libs.androidx.ui.tooling.preview)
|
||||
implementation(libs.androidx.material3)
|
||||
implementation(libs.androidx.compose.material.icons.core)
|
||||
implementation(libs.androidx.compose.material.icons.extended)
|
||||
|
||||
implementation(libs.hilt.android)
|
||||
implementation(libs.androidx.hilt.navigation.compose)
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.Manifest
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
@@ -24,7 +28,10 @@ class CalendarRepositorySmokeTest {
|
||||
|
||||
private fun newRepo(): CalendarRepositoryImpl {
|
||||
val dataSource = AndroidCalendarDataSource(context)
|
||||
return CalendarRepositoryImpl(dataSource, Dispatchers.IO)
|
||||
val store: DataStore<Preferences> = PreferenceDataStoreFactory.create(
|
||||
produceFile = { context.cacheDir.resolve("smoke_test_prefs.preferences_pb") },
|
||||
)
|
||||
return CalendarRepositoryImpl(dataSource, CalendarPrefs(store), Dispatchers.IO)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -23,6 +23,17 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Persists the per-app language (M4) on API < 33, where the platform
|
||||
per-app-languages API is unavailable. On 33+ this is a no-op. -->
|
||||
<service
|
||||
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
|
||||
android:enabled="false"
|
||||
android:exported="false">
|
||||
<meta-data
|
||||
android:name="autoStoreLocales"
|
||||
android:value="true" />
|
||||
</service>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -4,10 +4,16 @@ import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.ui.RootScreen
|
||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
|
||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -16,7 +22,19 @@ class MainActivity : ComponentActivity() {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
CalendulaTheme {
|
||||
// One activity-scoped SettingsViewModel drives both the theme here
|
||||
// and the Settings screen, so a theme change applies app-wide at once.
|
||||
val settingsViewModel: SettingsViewModel = hiltViewModel()
|
||||
val settings by settingsViewModel.state.collectAsStateWithLifecycle()
|
||||
val darkTheme = when (settings.themeMode) {
|
||||
ThemeMode.SYSTEM -> isSystemInDarkTheme()
|
||||
ThemeMode.LIGHT -> false
|
||||
ThemeMode.DARK -> true
|
||||
}
|
||||
CalendulaTheme(
|
||||
darkTheme = darkTheme,
|
||||
dynamicColor = settings.dynamicColor,
|
||||
) {
|
||||
RootScreen(modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
@@ -23,6 +25,7 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
class CalendarRepositoryImpl @Inject constructor(
|
||||
private val dataSource: CalendarDataSource,
|
||||
private val prefs: CalendarPrefs,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : CalendarRepository {
|
||||
|
||||
@@ -41,7 +44,13 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
.reQuery { dataSource.calendars() }
|
||||
.flowOn(io)
|
||||
|
||||
// Instances are filtered by the app-side hidden-calendar set (M3): an event
|
||||
// is dropped whenever the user has hidden its calendar. Re-runs when the
|
||||
// provider ticks *or* the hidden set changes — toggling a calendar in the
|
||||
// filter sheet updates every view immediately. [calendars] stays unfiltered
|
||||
// so the filter sheet can list and re-enable hidden calendars.
|
||||
override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> =
|
||||
combine(
|
||||
ticks
|
||||
.onStart { emit(Unit) }
|
||||
.reQuery {
|
||||
@@ -49,8 +58,12 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
beginMillis = range.start.toEpochMillis(),
|
||||
endMillis = range.endInclusive.toEpochMillis(),
|
||||
)
|
||||
}
|
||||
.flowOn(io)
|
||||
},
|
||||
prefs.hiddenCalendarIds,
|
||||
) { instances, hidden ->
|
||||
if (hidden.isEmpty()) instances
|
||||
else instances.filterNot { it.calendarId in hidden }
|
||||
}.flowOn(io)
|
||||
|
||||
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
|
||||
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
|
||||
|
||||
@@ -11,16 +11,26 @@ private const val TAG = "EventDetailMapper"
|
||||
|
||||
internal fun ColumnReader.toEventDetailCore(attendees: List<Attendee>): EventDetail? {
|
||||
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
||||
val end = getLong(EventDetailProjection.IDX_DTEND)
|
||||
|
||||
if (begin < 0L) {
|
||||
Log.w(TAG, "Dropping event with negative dtstart=$begin")
|
||||
return null
|
||||
}
|
||||
if (end < begin) {
|
||||
Log.w(TAG, "Dropping event with dtend=$end < dtstart=$begin")
|
||||
|
||||
// Recurring events store DURATION instead of DTEND, so the series row's
|
||||
// DTEND is null. Keep the event (end == begin); callers that opened a
|
||||
// specific occurrence supply the real per-occurrence times from
|
||||
// CalendarContract.Instances. Only a present-but-backwards DTEND is malformed.
|
||||
val end = if (isNull(EventDetailProjection.IDX_DTEND)) {
|
||||
begin
|
||||
} else {
|
||||
val rawEnd = getLong(EventDetailProjection.IDX_DTEND)
|
||||
if (rawEnd < begin) {
|
||||
Log.w(TAG, "Dropping event with dtend=$rawEnd < dtstart=$begin")
|
||||
return null
|
||||
}
|
||||
rawEnd
|
||||
}
|
||||
|
||||
val rawTitle = getString(EventDetailProjection.IDX_TITLE)
|
||||
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package de.jeanlucmakiola.calendula.data.prefs
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import java.time.temporal.WeekFields
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** Light/dark override. SYSTEM follows the device setting. */
|
||||
enum class ThemeMode { SYSTEM, LIGHT, DARK }
|
||||
|
||||
/** Week-start override. AUTO derives the first day from the active locale. */
|
||||
enum class WeekStartPref { AUTO, MONDAY, SUNDAY }
|
||||
|
||||
/**
|
||||
* Resolve the preference to a concrete first-day-of-week. AUTO reads the
|
||||
* locale's convention (e.g. Monday in DE, Sunday in en-US).
|
||||
*/
|
||||
fun WeekStartPref.resolveFirstDay(locale: Locale): DayOfWeek = when (this) {
|
||||
WeekStartPref.MONDAY -> DayOfWeek.MONDAY
|
||||
WeekStartPref.SUNDAY -> DayOfWeek.SUNDAY
|
||||
// java.time.DayOfWeek.value is ISO 1..7 (Mon..Sun) — same numbering kotlinx uses.
|
||||
WeekStartPref.AUTO -> DayOfWeek(WeekFields.of(locale).firstDayOfWeek.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Display settings (M4) persisted app-side: theme override, Material You
|
||||
* dynamic colour, and week start. Language is handled separately through
|
||||
* AppCompatDelegate (which persists its own per-app locale).
|
||||
*
|
||||
* Enum prefs round-trip by [Enum.name]; an unknown/garbage stored value falls
|
||||
* back to the default rather than throwing (see SettingsPrefsTest).
|
||||
*/
|
||||
@Singleton
|
||||
class SettingsPrefs @Inject constructor(
|
||||
private val store: DataStore<Preferences>,
|
||||
) {
|
||||
|
||||
val themeMode: Flow<ThemeMode> = store.data.map { prefs ->
|
||||
prefs[THEME_MODE_KEY].toEnum(ThemeMode.SYSTEM)
|
||||
}
|
||||
|
||||
val dynamicColor: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[DYNAMIC_COLOR_KEY] ?: true
|
||||
}
|
||||
|
||||
val weekStart: Flow<WeekStartPref> = store.data.map { prefs ->
|
||||
prefs[WEEK_START_KEY].toEnum(WeekStartPref.AUTO)
|
||||
}
|
||||
|
||||
suspend fun setThemeMode(mode: ThemeMode) {
|
||||
store.edit { it[THEME_MODE_KEY] = mode.name }
|
||||
}
|
||||
|
||||
suspend fun setDynamicColor(enabled: Boolean) {
|
||||
store.edit { it[DYNAMIC_COLOR_KEY] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setWeekStart(pref: WeekStartPref) {
|
||||
store.edit { it[WEEK_START_KEY] = pref.name }
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
|
||||
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
||||
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
|
||||
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default
|
||||
120
app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt
Normal file
120
app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt
Normal file
@@ -0,0 +1,120 @@
|
||||
package de.jeanlucmakiola.calendula.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
|
||||
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* Holds the active top-level view (spec M1) and swaps between the calendar
|
||||
* screens. Each screen owns its own ViewModel and date anchor; the view-switcher
|
||||
* pill in their top bars writes back here via [onSelectView].
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarHost(modifier: Modifier = Modifier) {
|
||||
var view by rememberSaveable { mutableStateOf(CalendarView.Week) }
|
||||
val onSelectView: (CalendarView) -> Unit = { view = it }
|
||||
|
||||
// Tapping a day in the month grid opens the day view anchored to that date.
|
||||
var pendingDayIso by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val onOpenDay: (LocalDate) -> Unit = { date ->
|
||||
pendingDayIso = date.toString()
|
||||
view = CalendarView.Day
|
||||
}
|
||||
|
||||
// The event-detail screen (S4) is a full-screen destination hoisted here so
|
||||
// it overlays whichever calendar view is active. We forward the tapped
|
||||
// occurrence's own times (eventId + begin + end, packed as a saveable
|
||||
// long[]) so recurring events show the correct date, not the series start.
|
||||
// [heldKey] keeps the last shown key alive through the slide-out (when
|
||||
// [detailKey] is cleared); it is set in the tap callback — never a [0,0,0]
|
||||
// placeholder — so the destination never loads a bogus id=0 on first frame.
|
||||
var detailKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
|
||||
var heldKey by remember { mutableStateOf<LongArray?>(null) }
|
||||
val onEventClick: (EventInstance) -> Unit = { event ->
|
||||
val key = longArrayOf(
|
||||
event.eventId,
|
||||
event.start.toEpochMilliseconds(),
|
||||
event.end.toEpochMilliseconds(),
|
||||
)
|
||||
heldKey = key
|
||||
detailKey = key
|
||||
}
|
||||
|
||||
// Settings (M4) is hoisted here so it overlays whichever calendar view is
|
||||
// active and survives view switches. (The calendar filter now lives inline
|
||||
// in the navigation drawer, so no overlay state is needed for it.)
|
||||
var showSettings by rememberSaveable { mutableStateOf(false) }
|
||||
val onOpenSettings = { showSettings = true }
|
||||
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
when (view) {
|
||||
CalendarView.Week -> WeekScreen(
|
||||
selectedView = view,
|
||||
onSelectView = onSelectView,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
)
|
||||
CalendarView.Day -> DayScreen(
|
||||
selectedView = view,
|
||||
onSelectView = onSelectView,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
initialDateIso = pendingDayIso,
|
||||
)
|
||||
CalendarView.Month -> MonthScreen(
|
||||
selectedView = view,
|
||||
onSelectView = onSelectView,
|
||||
onOpenDay = onOpenDay,
|
||||
onOpenSettings = onOpenSettings,
|
||||
)
|
||||
}
|
||||
|
||||
// Prefer the live key; fall back to the held one only while sliding out.
|
||||
val activeKey = detailKey ?: heldKey
|
||||
AnimatedVisibility(
|
||||
visible = detailKey != null,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
) {
|
||||
activeKey?.let { key ->
|
||||
EventDetailScreen(
|
||||
eventId = key[0],
|
||||
beginMillis = key[1],
|
||||
endMillis = key[2],
|
||||
onBack = { detailKey = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Settings (M4) — full-screen destination, slides over the calendar.
|
||||
AnimatedVisibility(
|
||||
visible = showSettings,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
) {
|
||||
SettingsScreen(onBack = { showSettings = false })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,21 +2,18 @@ package de.jeanlucmakiola.calendula.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import de.jeanlucmakiola.calendula.ui.debug.DebugScreen
|
||||
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
|
||||
|
||||
@Composable
|
||||
@@ -42,14 +39,12 @@ fun RootScreen(modifier: Modifier = Modifier) {
|
||||
onDispose { lifecycle.removeObserver(obs) }
|
||||
}
|
||||
|
||||
Scaffold(modifier = modifier) { innerPadding ->
|
||||
if (hasPermission) {
|
||||
DebugScreen(modifier = Modifier.padding(innerPadding))
|
||||
CalendarHost(modifier = modifier)
|
||||
} else {
|
||||
PermissionScreen(
|
||||
onGranted = { hasPermission = true },
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* Soften a raw calendar color toward a pastel that fits the active theme.
|
||||
* - Keeps the hue (so users still recognise their calendars)
|
||||
* - Caps saturation so harsh provider colors stop screaming
|
||||
* - Pins value/brightness to a band that reads on both light and dark surfaces
|
||||
*/
|
||||
fun pastelize(rawArgb: Int, dark: Boolean): Color {
|
||||
val hsv = FloatArray(3)
|
||||
android.graphics.Color.colorToHSV(rawArgb, hsv)
|
||||
hsv[1] = (hsv[1] * 0.6f).coerceIn(0.25f, 0.65f)
|
||||
hsv[2] = if (dark) 0.82f else 0.72f
|
||||
return Color(android.graphics.Color.HSVToColor(hsv))
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Today
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.material3.NavigationDrawerItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
|
||||
|
||||
/**
|
||||
* Navigation drawer shared by every top-level calendar screen.
|
||||
*
|
||||
* Visual language (kept deliberately small so sizes don't drift):
|
||||
* - Drawer title — `titleLarge`
|
||||
* - Section headers (e.g. "Calendars") — `titleSmall`, primary, text only
|
||||
* - Nav items (Today / Settings) — Material `NavigationDrawerItem`
|
||||
* (`labelLarge` label + a single 24dp leading icon)
|
||||
*
|
||||
* Hosts the per-calendar visibility filter (M3) inline — the calendar list with
|
||||
* its checkboxes lives here rather than in a separate sheet — plus the "today"
|
||||
* jump and a Settings entry (M4). The host screen owns the drawer state.
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarDrawer(
|
||||
onToday: () -> Unit,
|
||||
onSettings: () -> Unit,
|
||||
) {
|
||||
ModalDrawerSheet {
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
Text(
|
||||
text = stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(horizontal = 28.dp, vertical = 24.dp),
|
||||
)
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(8.dp))
|
||||
NavigationDrawerItem(
|
||||
icon = { Icon(Icons.Filled.Today, contentDescription = null) },
|
||||
label = { Text(stringResource(R.string.month_today_action)) },
|
||||
selected = false,
|
||||
onClick = onToday,
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
HorizontalDivider()
|
||||
|
||||
// Calendars (M3) — visibility checkboxes, scrollable, takes the slack
|
||||
// between the top actions and the pinned Settings entry.
|
||||
DrawerSectionHeader(stringResource(R.string.filter_title))
|
||||
CalendarFilterList(modifier = Modifier.weight(1f))
|
||||
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(8.dp))
|
||||
NavigationDrawerItem(
|
||||
icon = { Icon(Icons.Filled.Settings, contentDescription = null) },
|
||||
label = { Text(stringResource(R.string.month_action_settings)) },
|
||||
selected = false,
|
||||
onClick = onSettings,
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Top-level grouping label in the drawer. Text only, so it never reads as a
|
||||
* tappable nav item. */
|
||||
@Composable
|
||||
private fun DrawerSectionHeader(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
|
||||
/**
|
||||
* Full-screen failure state shared by every calendar screen (spec §7).
|
||||
* One explanation line + one recovery action, never a toast.
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarFailure(reason: FailureReason, onRetry: () -> Unit) {
|
||||
val titleRes = when (reason) {
|
||||
FailureReason.PermissionRevoked -> R.string.state_failure_permission
|
||||
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars
|
||||
FailureReason.ProviderUnavailable -> R.string.state_failure_provider
|
||||
FailureReason.Unknown,
|
||||
FailureReason.EventNotFound -> R.string.state_failure_unknown
|
||||
}
|
||||
val actionRes = when (reason) {
|
||||
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars_action
|
||||
FailureReason.PermissionRevoked -> R.string.state_failure_permission_action
|
||||
else -> R.string.state_retry
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(titleRes),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
FilledTonalButton(onClick = onRetry) {
|
||||
Text(stringResource(actionRes))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.ContentTransform
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
|
||||
/**
|
||||
* The M3 Expressive spatial spring used for the month/week slide: the *fast*
|
||||
* spring-physics spec from the active motion scheme — snappy with a subtle
|
||||
* springy settle, rather than a fixed easing curve.
|
||||
*
|
||||
* Read it in a composable scope (this helper) so it can be captured by the
|
||||
* non-composable `AnimatedContent` transitionSpec lambda.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
|
||||
MaterialTheme.motionScheme.fastSpatialSpec()
|
||||
|
||||
/**
|
||||
* Horizontal slide for navigating between adjacent months/weeks.
|
||||
*
|
||||
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
|
||||
* (e.g. "today"); a jump reuses the forward direction.
|
||||
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
|
||||
*/
|
||||
fun calendarSlideTransition(
|
||||
slideDir: Int,
|
||||
spec: FiniteAnimationSpec<IntOffset>,
|
||||
): ContentTransform {
|
||||
val dir = if (slideDir == 0) 1 else slideDir
|
||||
return slideInHorizontally(spec) { w -> dir * w }
|
||||
.togetherWith(slideOutHorizontally(spec) { w -> -dir * w })
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
/**
|
||||
* The top-level calendar views the user can switch between (spec M1).
|
||||
* Day is declared but not yet implemented (v0.5) — see [IMPLEMENTED_VIEWS].
|
||||
*/
|
||||
enum class CalendarView {
|
||||
Month,
|
||||
Week,
|
||||
Day,
|
||||
}
|
||||
|
||||
/**
|
||||
* Views that actually have a screen today. The view-switcher pill cycles
|
||||
* through these in order.
|
||||
*/
|
||||
val IMPLEMENTED_VIEWS: List<CalendarView> =
|
||||
listOf(CalendarView.Month, CalendarView.Week, CalendarView.Day)
|
||||
|
||||
/** Next view in [available], wrapping around. Falls back to Month if absent. */
|
||||
fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView {
|
||||
val i = available.indexOf(this)
|
||||
if (i < 0) return available.firstOrNull() ?: CalendarView.Month
|
||||
return available[(i + 1) % available.size]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.core.os.ConfigurationCompat
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Current display [Locale], read observably from [LocalConfiguration] so the UI
|
||||
* recomposes after a locale change (lint: NonObservableLocale). Used for
|
||||
* weekday/month name formatting.
|
||||
*/
|
||||
@Composable
|
||||
fun currentLocale(): Locale {
|
||||
val configuration = LocalConfiguration.current
|
||||
return remember(configuration) {
|
||||
ConfigurationCompat.getLocales(configuration).get(0) ?: Locale.getDefault()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
|
||||
/**
|
||||
* Top-bar pill that shows the current view and cycles to the next one on tap
|
||||
* (spec M1: Month → Week → Day → Month, restricted to [IMPLEMENTED_VIEWS]).
|
||||
*/
|
||||
@Composable
|
||||
fun ViewSwitcherPill(
|
||||
current: CalendarView,
|
||||
onCycle: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val labelRes = when (current) {
|
||||
CalendarView.Month -> R.string.view_month
|
||||
CalendarView.Week -> R.string.view_week
|
||||
CalendarView.Day -> R.string.view_day
|
||||
}
|
||||
FilledTonalButton(
|
||||
onClick = onCycle,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
modifier = modifier,
|
||||
) {
|
||||
Text(stringResource(labelRes))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
package de.jeanlucmakiola.calendula.ui.day
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
|
||||
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.LocalDate
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val HOUR_HEIGHT = 56.dp
|
||||
private val GUTTER_WIDTH = 48.dp
|
||||
private val MIN_EVENT_HEIGHT = 24.dp
|
||||
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
||||
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||
|
||||
/** Total all-day strip height for the day (0 when there are no all-day events). */
|
||||
private fun DayUiState.Success.allDayStripHeight(): Dp {
|
||||
if (allDay.isEmpty()) return 0.dp
|
||||
val lanes = allDay.maxOf { it.lane } + 1
|
||||
return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DayScreen(
|
||||
selectedView: CalendarView,
|
||||
onSelectView: (CalendarView) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
initialDateIso: String? = null,
|
||||
viewModel: DayViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val date by viewModel.date.collectAsStateWithLifecycle()
|
||||
|
||||
// When opened from the month grid, anchor to the tapped date.
|
||||
LaunchedEffect(initialDateIso) {
|
||||
initialDateIso?.let { viewModel.goToDate(LocalDate.parse(it)) }
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// The all-day strip shares the app bar's scrolled colour so the whole top
|
||||
// region elevates together once the timeline scrolls under it.
|
||||
val topSectionColor by animateColorAsState(
|
||||
targetValue = if (scrollBehavior.state.overlappedFraction > 0.01f) {
|
||||
MaterialTheme.colorScheme.surfaceContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
},
|
||||
label = "day-top-section-color",
|
||||
)
|
||||
|
||||
val isOnToday = when (val s = state) {
|
||||
is DayUiState.Success -> s.date == s.today
|
||||
else -> true
|
||||
}
|
||||
|
||||
// Slide direction for the day transition: +1 = next, -1 = prev, 0 = jump.
|
||||
var slideDir by remember { mutableIntStateOf(0) }
|
||||
val goNext = { slideDir = 1; viewModel.goToNext() }
|
||||
val goPrev = { slideDir = -1; viewModel.goToPrev() }
|
||||
val jumpToToday = { slideDir = 0; viewModel.goToToday() }
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
// Open only via the menu button — edge-swipe would fight the day swipe.
|
||||
gesturesEnabled = drawerState.isOpen,
|
||||
drawerContent = {
|
||||
CalendarDrawer(
|
||||
onToday = { jumpToToday(); scope.launch { drawerState.close() } },
|
||||
onSettings = {
|
||||
onOpenSettings()
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
DayTopBar(
|
||||
date = date,
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next()) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
AnimatedVisibility(
|
||||
visible = !isOnToday,
|
||||
enter = scaleIn(),
|
||||
exit = scaleOut(),
|
||||
) {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = jumpToToday,
|
||||
icon = { Icon(Icons.Default.Refresh, contentDescription = null) },
|
||||
text = { Text(stringResource(R.string.day_today_action)) },
|
||||
)
|
||||
}
|
||||
},
|
||||
) { innerPadding ->
|
||||
DayContent(
|
||||
state = state,
|
||||
slideDir = slideDir,
|
||||
topSectionColor = topSectionColor,
|
||||
onSwipeNext = goNext,
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
onEventClick = onEventClick,
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DayContent(
|
||||
state: DayUiState,
|
||||
slideDir: Int,
|
||||
topSectionColor: Color,
|
||||
onSwipeNext: () -> Unit,
|
||||
onSwipePrev: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val threshold = with(density) { 24.dp.toPx() }
|
||||
var dragAccum by remember { mutableFloatStateOf(0f) }
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
|
||||
// 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
|
||||
// into the day view (i.e. when arriving from the month/week view).
|
||||
val scrollState = rememberScrollState()
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||
val maxV = scrollState.maxValue
|
||||
val target = with(density) {
|
||||
(HOUR_HEIGHT.toPx() * 12 - (HOUR_HEIGHT.toPx() * 24 - maxV) / 2f).roundToInt()
|
||||
}.coerceIn(0, maxV)
|
||||
scrollState.scrollTo(target)
|
||||
}
|
||||
|
||||
// Single, hoisted all-day strip height — shared by the outgoing and incoming
|
||||
// day during a swipe, so the strip slides along but never jumps in height.
|
||||
val targetAllDayHeight = (state as? DayUiState.Success)?.allDayStripHeight() ?: 0.dp
|
||||
val allDayHeight by animateDpAsState(
|
||||
targetValue = targetAllDayHeight,
|
||||
label = "day-all-day-strip-height",
|
||||
)
|
||||
|
||||
// Whole-page horizontal swipe, one level above the timeline's vertical
|
||||
// scroll: a horizontal drag crosses this detector's slop, while a vertical
|
||||
// drag is consumed by the inner scroll first — the two gestures coexist.
|
||||
val swipeModifier = Modifier.pointerInput(Unit) {
|
||||
detectHorizontalDragGestures(
|
||||
onDragStart = { dragAccum = 0f },
|
||||
onDragEnd = {
|
||||
when {
|
||||
dragAccum < -threshold -> onSwipeNext()
|
||||
dragAccum > threshold -> onSwipePrev()
|
||||
}
|
||||
dragAccum = 0f
|
||||
},
|
||||
onDragCancel = { dragAccum = 0f },
|
||||
onHorizontalDrag = { _, drag -> dragAccum += drag },
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedContent(
|
||||
targetState = state,
|
||||
modifier = modifier.then(swipeModifier),
|
||||
contentKey = { s ->
|
||||
when (s) {
|
||||
is DayUiState.Success -> "success-${s.date}"
|
||||
is DayUiState.Failure -> "failure-${s.reason}"
|
||||
DayUiState.Loading -> "loading"
|
||||
}
|
||||
},
|
||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
|
||||
label = "day-transition",
|
||||
) { s ->
|
||||
when (s) {
|
||||
DayUiState.Loading -> DayLoading()
|
||||
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||
is DayUiState.Success -> DaySuccess(
|
||||
state = s,
|
||||
topSectionColor = topSectionColor,
|
||||
scrollState = scrollState,
|
||||
allDayHeight = allDayHeight,
|
||||
onEventClick = onEventClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DaySuccess(
|
||||
state: DayUiState.Success,
|
||||
topSectionColor: Color,
|
||||
scrollState: ScrollState,
|
||||
allDayHeight: Dp,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// All-day strip collapses to nothing when the day has no all-day events,
|
||||
// so the timeline sits directly under the app bar.
|
||||
AllDayStrip(
|
||||
state = state,
|
||||
height = allDayHeight,
|
||||
onEventClick = onEventClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(topSectionColor),
|
||||
)
|
||||
// Breathing room between the (colour-shifting) top section and the
|
||||
// scrolling timeline below.
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Timeline(state = state, scrollState = scrollState, onEventClick = onEventClick)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun DayTopBar(
|
||||
date: LocalDate,
|
||||
selectedView: CalendarView,
|
||||
onCycleView: () -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||
) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = formatDayTitle(date),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onOpenDrawer) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Menu,
|
||||
contentDescription = stringResource(R.string.month_open_menu),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
ViewSwitcherPill(
|
||||
current = selectedView,
|
||||
onCycle = onCycleView,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AllDayStrip(
|
||||
state: DayUiState.Success,
|
||||
height: Dp,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
// Height is hoisted + animated so it resizes smoothly; padding sits
|
||||
// inside it so the content area is lanes * row height.
|
||||
.height(height)
|
||||
.padding(vertical = ALL_DAY_VERTICAL_PADDING),
|
||||
) {
|
||||
// Keep the gutter-width offset so the bars line up with the day column.
|
||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||
// Bars are positioned absolutely by lane (vertical stacking); each spans
|
||||
// the full day-column width. clipToBounds keeps bars from spilling out
|
||||
// while the height animates.
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.clipToBounds(),
|
||||
) {
|
||||
val barWidth = maxWidth
|
||||
state.allDay.forEach { span ->
|
||||
AllDayBar(
|
||||
event = span.event,
|
||||
dark = dark,
|
||||
onClick = { onEventClick(span.event) },
|
||||
modifier = Modifier
|
||||
.offset(y = ALL_DAY_ROW_HEIGHT * span.lane)
|
||||
.width(barWidth)
|
||||
.height(ALL_DAY_ROW_HEIGHT)
|
||||
.padding(horizontal = 1.dp, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AllDayBar(
|
||||
event: EventInstance,
|
||||
dark: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
.semantics { contentDescription = title },
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = Color.Black.copy(alpha = 0.8f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Timeline(
|
||||
state: DayUiState.Success,
|
||||
scrollState: ScrollState,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
) {
|
||||
val totalHeight = HOUR_HEIGHT * 24
|
||||
val dark = isSystemInDarkTheme()
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// Gutter and day column are two scroll viewports that SHARE one scroll
|
||||
// state, so they stay perfectly aligned. The day-column viewport is a
|
||||
// static, rounded-clipped window — the content scrolls inside it, so the
|
||||
// soft corners are permanent at any scroll position.
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
// Hour gutter (scrolls in sync with the day column)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(GUTTER_WIDTH)
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
(0 until 24).forEach { h ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(HOUR_HEIGHT),
|
||||
) {
|
||||
if (h > 0) {
|
||||
Text(
|
||||
text = "%02d".format(h),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.offset(y = (-6).dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Day column: rounded, clipped scroll viewport (permanent corners).
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
DayColumnCard(
|
||||
blocks = state.timed,
|
||||
dark = dark,
|
||||
onEventClick = onEventClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(totalHeight),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DayColumnCard(
|
||||
blocks: List<TimedBlock>,
|
||||
dark: Boolean,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(
|
||||
// Plain rectangular column — the soft corners come from the outer
|
||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||
shape = RectangleShape,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
modifier = modifier,
|
||||
) {
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
val colWidth = maxWidth
|
||||
blocks.forEach { block ->
|
||||
val laneWidth = colWidth / block.laneCount
|
||||
val top = HOUR_HEIGHT * (block.startMin / 60f)
|
||||
val rawHeight = HOUR_HEIGHT * ((block.endMin - block.startMin) / 60f)
|
||||
val height = if (rawHeight < MIN_EVENT_HEIGHT) MIN_EVENT_HEIGHT else rawHeight
|
||||
EventBlock(
|
||||
block = block,
|
||||
dark = dark,
|
||||
onClick = { onEventClick(block.event) },
|
||||
modifier = Modifier
|
||||
.offset(x = laneWidth * block.lane, y = top)
|
||||
.width(laneWidth)
|
||||
.height(height)
|
||||
.padding(horizontal = 1.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventBlock(
|
||||
block: TimedBlock,
|
||||
dark: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
val timeLabel = "${minToHm(block.startMin)}–${minToHm(block.endMin)}"
|
||||
val showTime = block.endMin - block.startMin >= 45
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||
.semantics { contentDescription = "$title, $timeLabel" },
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = if (showTime) 1 else 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = Color.Black.copy(alpha = 0.85f),
|
||||
)
|
||||
if (showTime) {
|
||||
Text(
|
||||
text = timeLabel,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = Color.Black.copy(alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DayLoading() {
|
||||
val totalHeight = HOUR_HEIGHT * 24
|
||||
val scrollState = rememberScrollState()
|
||||
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
|
||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(totalHeight)
|
||||
.padding(horizontal = 2.dp)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun minToHm(min: Int): String =
|
||||
if (min >= MINUTES_PER_DAY) "24:00" else "%02d:%02d".format(min / 60, min % 60)
|
||||
|
||||
private fun formatDayTitle(date: LocalDate): String {
|
||||
val locale = Locale.getDefault()
|
||||
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
||||
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||
return "$weekday, ${date.day}. $monthName ${date.year}"
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.jeanlucmakiola.calendula.ui.day
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.ui.week.AllDaySpan
|
||||
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* The day view is a single-column slice of the week view (spec S3). It reuses the
|
||||
* week's [TimedBlock] and [AllDaySpan] layout primitives — for one day, all-day
|
||||
* spans collapse to a single column ([AllDaySpan.startCol] == [AllDaySpan.endCol]
|
||||
* == 0) and only their [AllDaySpan.lane] (vertical stacking) matters.
|
||||
*/
|
||||
sealed interface DayUiState {
|
||||
data object Loading : DayUiState
|
||||
data class Failure(val reason: FailureReason) : DayUiState
|
||||
data class Success(
|
||||
val date: LocalDate,
|
||||
val today: LocalDate,
|
||||
/** All-day/multi-day events covering this day, stacked by lane. */
|
||||
val allDay: List<AllDaySpan>,
|
||||
/** Timed events clipped to this day with overlap lanes resolved. */
|
||||
val timed: List<TimedBlock>,
|
||||
) : DayUiState
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package de.jeanlucmakiola.calendula.ui.day
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
|
||||
import de.jeanlucmakiola.calendula.ui.week.layoutDay
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.minus
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class DayViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
private val zone = TimeZone.currentSystemDefault()
|
||||
|
||||
private val todayDate: LocalDate
|
||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||
|
||||
private val _date = MutableStateFlow(todayDate)
|
||||
val date: StateFlow<LocalDate> = _date
|
||||
|
||||
val state: StateFlow<DayUiState> = _date
|
||||
.flatMapLatest { day ->
|
||||
val range = dayRange(day, zone)
|
||||
combine(
|
||||
repository.calendars(),
|
||||
repository.instances(range),
|
||||
) { calendars, instances ->
|
||||
buildState(day, calendars, instances)
|
||||
}
|
||||
}
|
||||
.catch { emit(DayUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = DayUiState.Loading,
|
||||
)
|
||||
|
||||
fun goToPrev() {
|
||||
_date.value = _date.value.minus(1, DateTimeUnit.DAY)
|
||||
}
|
||||
|
||||
fun goToNext() {
|
||||
_date.value = _date.value.plus(1, DateTimeUnit.DAY)
|
||||
}
|
||||
|
||||
fun goToToday() {
|
||||
_date.value = todayDate
|
||||
}
|
||||
|
||||
/** Jump to a specific date (e.g. when opened from the month grid). */
|
||||
fun goToDate(date: LocalDate) {
|
||||
_date.value = date
|
||||
}
|
||||
|
||||
private fun buildState(
|
||||
day: LocalDate,
|
||||
calendars: List<CalendarSource>,
|
||||
instances: List<EventInstance>,
|
||||
): DayUiState {
|
||||
if (calendars.isEmpty()) {
|
||||
return DayUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
}
|
||||
val days = listOf(day)
|
||||
val allDay = instances.filter { it.isAllDay }
|
||||
val timed = instances.filterNot { it.isAllDay }
|
||||
return DayUiState.Success(
|
||||
date = day,
|
||||
today = todayDate,
|
||||
allDay = layoutAllDay(allDay, days, zone),
|
||||
timed = layoutDay(timed, day, zone),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Half-open instant range covering the single calendar [date]. */
|
||||
internal fun dayRange(date: LocalDate, zone: TimeZone): ClosedRange<Instant> {
|
||||
val from = date.atStartOfDayIn(zone)
|
||||
val to = date.atTime(23, 59, 59).toInstant(zone)
|
||||
return from..to
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.debug
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
|
||||
@Composable
|
||||
fun DebugScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: DebugViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
DebugBanner()
|
||||
when (val s = state) {
|
||||
DebugUiState.Loading -> LoadingContent()
|
||||
is DebugUiState.Failure -> FailureContent()
|
||||
is DebugUiState.Success -> SuccessContent(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DebugBanner() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.tertiaryContainer)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.debug_banner),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingContent() {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FailureContent() {
|
||||
Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = stringResource(R.string.state_failure_provider),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SuccessContent(state: DebugUiState.Success) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
item { SectionHeader(stringResource(R.string.debug_calendars_header)) }
|
||||
if (state.calendars.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.debug_no_calendars),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(state.calendars, key = { it.id }) { CalendarRow(it) }
|
||||
}
|
||||
|
||||
item { Spacer(Modifier.height(16.dp)) }
|
||||
item { SectionHeader(stringResource(R.string.debug_events_header)) }
|
||||
|
||||
if (state.nextEvents.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.debug_no_events),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(state.nextEvents, key = { it.instanceId }) { EventRow(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(text: String) {
|
||||
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
||||
Text(text = text, style = MaterialTheme.typography.titleMedium)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CalendarRow(cal: CalendarSource) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(12.dp)
|
||||
.background(Color(cal.color), CircleShape),
|
||||
)
|
||||
Text(
|
||||
text = " ${cal.displayName} (${cal.accountName})",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventRow(event: EventInstance) {
|
||||
val zone = TimeZone.currentSystemDefault()
|
||||
val start = event.start.toLocalDateTime(zone)
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Text(text = event.title, style = MaterialTheme.typography.bodyMedium)
|
||||
val date = "%04d-%02d-%02d".format(start.year, start.month.ordinal + 1, start.day)
|
||||
val time = "%02d:%02d".format(start.hour, start.minute)
|
||||
Text(
|
||||
text = "$date $time",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.debug
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
|
||||
sealed interface DebugUiState {
|
||||
data object Loading : DebugUiState
|
||||
data class Failure(val reason: FailureReason) : DebugUiState
|
||||
data class Success(
|
||||
val calendars: List<CalendarSource>,
|
||||
val nextEvents: List<EventInstance>,
|
||||
) : DebugUiState
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.debug
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val MAX_DEBUG_EVENTS = 50
|
||||
private val DEBUG_WINDOW = 30.days
|
||||
|
||||
@HiltViewModel
|
||||
class DebugViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
val state: StateFlow<DebugUiState> = run {
|
||||
val now = Instant.fromEpochMilliseconds(System.currentTimeMillis())
|
||||
val range = now..(now + DEBUG_WINDOW)
|
||||
combine(
|
||||
repository.calendars(),
|
||||
repository.instances(range),
|
||||
) { calendars, instances ->
|
||||
DebugUiState.Success(
|
||||
calendars = calendars,
|
||||
nextEvents = instances.take(MAX_DEBUG_EVENTS),
|
||||
) as DebugUiState
|
||||
}
|
||||
.catch { emit(DebugUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = DebugUiState.Loading,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
package de.jeanlucmakiola.calendula.ui.detail
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.icu.text.ListFormatter
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.People
|
||||
import androidx.compose.material.icons.filled.Place
|
||||
import androidx.compose.material.icons.filled.Repeat
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.Attendee
|
||||
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import kotlinx.datetime.TimeZone
|
||||
import java.time.DayOfWeek
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Read-only full-screen event detail (spec S4, realised as a navigation
|
||||
* destination rather than a bottom sheet — MD3 list→detail pattern). Back
|
||||
* gesture and the top-bar arrow both return to the calendar. The only action is
|
||||
* tapping the location to open a maps intent.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EventDetailScreen(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
endMillis: Long,
|
||||
onBack: () -> Unit,
|
||||
viewModel: EventDetailViewModel = hiltViewModel(),
|
||||
) {
|
||||
LaunchedEffect(eventId, beginMillis, endMillis) {
|
||||
viewModel.open(eventId, beginMillis, endMillis)
|
||||
}
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
BackHandler(onBack = onBack)
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.event_detail_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { /* TODO: edit event (V2) */ }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Edit,
|
||||
contentDescription = stringResource(R.string.event_detail_edit),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { /* TODO: delete event (V2) */ }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = stringResource(R.string.event_detail_delete),
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
val contentModifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
when (val s = state) {
|
||||
EventDetailUiState.Loading -> EventDetailLoading(contentModifier)
|
||||
is EventDetailUiState.Failure -> CalendarFailure(
|
||||
reason = s.reason,
|
||||
onRetry = viewModel::retry,
|
||||
)
|
||||
is EventDetailUiState.Success -> EventDetailContent(s, contentModifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modifier = Modifier) {
|
||||
val detail = state.detail
|
||||
val instance = detail.instance
|
||||
val dark = isSystemInDarkTheme()
|
||||
val locale = currentDetailLocale()
|
||||
val accent = pastelize(instance.color, dark)
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp),
|
||||
) {
|
||||
// Title with a short accent line in the calendar colour underneath.
|
||||
Text(
|
||||
text = instance.title.ifBlank { stringResource(R.string.event_untitled) },
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(48.dp)
|
||||
.height(3.dp)
|
||||
.background(accent, RoundedCornerShape(2.dp)),
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
// Every piece of info shares one card design: a tonal container with a
|
||||
// leading icon in the gutter and the value to the right. 12dp gaps stack
|
||||
// them cleanly.
|
||||
val gap = 12.dp
|
||||
|
||||
// "When" — date/all-day plus the time range.
|
||||
val (whenPrimary, whenSecondary) = formatWhen(instance, TimeZone.currentSystemDefault(), locale)
|
||||
DetailCard(icon = Icons.Default.Schedule, iconContentDescription = null) {
|
||||
Text(text = whenPrimary, style = MaterialTheme.typography.titleMedium)
|
||||
if (whenSecondary != null) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = whenSecondary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Calendar — icon tinted in the calendar colour conveys identity, so no
|
||||
// separate colour dot is needed.
|
||||
Spacer(Modifier.height(gap))
|
||||
DetailCard(
|
||||
icon = Icons.Default.CalendarMonth,
|
||||
iconTint = accent,
|
||||
iconContentDescription = stringResource(R.string.event_detail_calendar),
|
||||
) {
|
||||
Text(
|
||||
text = state.calendarName ?: stringResource(R.string.event_detail_calendar_unknown),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
|
||||
// Location (conditional, tap → maps).
|
||||
instance.location?.takeIf { it.isNotBlank() }?.let { location ->
|
||||
val context = LocalContext.current
|
||||
Spacer(Modifier.height(gap))
|
||||
DetailCard(
|
||||
icon = Icons.Default.Place,
|
||||
iconContentDescription = stringResource(R.string.event_detail_location),
|
||||
) {
|
||||
Text(
|
||||
text = location,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { openInMaps(context, location) }
|
||||
.padding(vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Description (conditional).
|
||||
detail.description?.takeIf { it.isNotBlank() }?.let { description ->
|
||||
Spacer(Modifier.height(gap))
|
||||
DetailCard(
|
||||
icon = Icons.AutoMirrored.Filled.Notes,
|
||||
iconContentDescription = stringResource(R.string.event_detail_description),
|
||||
) {
|
||||
Text(text = description, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
// Attendees (conditional).
|
||||
detail.attendees.takeIf { it.isNotEmpty() }?.let { attendees ->
|
||||
Spacer(Modifier.height(gap))
|
||||
DetailCard(
|
||||
icon = Icons.Default.People,
|
||||
iconContentDescription = stringResource(R.string.event_detail_attendees),
|
||||
) {
|
||||
attendees.forEach { AttendeeRow(it) }
|
||||
}
|
||||
}
|
||||
|
||||
// Recurrence (conditional) — humanised from the RRULE.
|
||||
detail.rrule?.takeIf { it.isNotBlank() }?.let { rrule ->
|
||||
Spacer(Modifier.height(gap))
|
||||
DetailCard(
|
||||
icon = Icons.Default.Repeat,
|
||||
iconContentDescription = stringResource(R.string.event_detail_recurrence),
|
||||
) {
|
||||
Text(
|
||||
text = recurrenceText(rrule, locale),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One info card: tonal container, leading icon in the gutter, value to the right. */
|
||||
@Composable
|
||||
private fun DetailCard(
|
||||
icon: ImageVector,
|
||||
iconContentDescription: String?,
|
||||
iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = iconContentDescription,
|
||||
tint = iconTint,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f), content = content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttendeeRow(attendee: Attendee) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 3.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = attendee.name.ifBlank { attendee.email.orEmpty() },
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(attendeeStatusLabel(attendee.status)),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventDetailLoading(modifier: Modifier = Modifier) {
|
||||
Column(modifier = modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp)) {
|
||||
SkeletonBar(widthFraction = 0.7f, height = 32.dp)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
SkeletonBar(widthFraction = 1f, height = 64.dp)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
SkeletonBar(widthFraction = 0.35f, height = 14.dp)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SkeletonBar(widthFraction = 0.6f, height = 16.dp)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
SkeletonBar(widthFraction = 0.35f, height = 14.dp)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SkeletonBar(widthFraction = 0.8f, height = 16.dp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SkeletonBar(widthFraction: Float, height: Dp) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(widthFraction)
|
||||
.height(height)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
RoundedCornerShape(8.dp),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// --- helpers -------------------------------------------------------------
|
||||
|
||||
// Observable locale read (shared helper) — avoids NonObservableLocale /
|
||||
// LocalContextConfigurationRead lint by going through LocalConfiguration.
|
||||
@Composable
|
||||
private fun currentDetailLocale(): Locale = currentLocale()
|
||||
|
||||
private fun attendeeStatusLabel(status: AttendeeStatus): Int = when (status) {
|
||||
AttendeeStatus.Accepted -> R.string.event_attendee_accepted
|
||||
AttendeeStatus.Declined -> R.string.event_attendee_declined
|
||||
AttendeeStatus.Tentative -> R.string.event_attendee_tentative
|
||||
AttendeeStatus.NeedsAction -> R.string.event_attendee_needs_action
|
||||
AttendeeStatus.Unknown -> R.string.event_attendee_unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Humanise an RFC 5545 RRULE into a localized phrase, e.g.
|
||||
* "Every week on Tue and Thu until 31 Dec 2026" or "Every day, 10 times".
|
||||
* Falls back to a generic label for rules we don't render in full (ordinal
|
||||
* monthly/yearly BYDAY, etc.).
|
||||
*/
|
||||
@Composable
|
||||
private fun recurrenceText(rrule: String, locale: Locale): AnnotatedString {
|
||||
val parts = rrule.removePrefix("RRULE:").split(';').mapNotNull { token ->
|
||||
val eq = token.indexOf('=')
|
||||
if (eq <= 0) null else token.substring(0, eq).uppercase() to token.substring(eq + 1)
|
||||
}.toMap()
|
||||
|
||||
val freq = parts["FREQ"]?.uppercase()
|
||||
val interval = parts["INTERVAL"]?.toIntOrNull() ?: 1
|
||||
val base = when (freq) {
|
||||
"DAILY" -> if (interval == 1) stringResource(R.string.recurrence_daily)
|
||||
else stringResource(R.string.recurrence_every_n_days, interval)
|
||||
"WEEKLY" -> if (interval == 1) stringResource(R.string.recurrence_weekly)
|
||||
else stringResource(R.string.recurrence_every_n_weeks, interval)
|
||||
"MONTHLY" -> if (interval == 1) stringResource(R.string.recurrence_monthly)
|
||||
else stringResource(R.string.recurrence_every_n_months, interval)
|
||||
"YEARLY" -> if (interval == 1) stringResource(R.string.recurrence_yearly)
|
||||
else stringResource(R.string.recurrence_every_n_years, interval)
|
||||
else -> return AnnotatedString(stringResource(R.string.event_detail_recurring))
|
||||
}
|
||||
|
||||
// Weekly + BYDAY → "<base> on <days>"; other BYDAY forms keep just the base.
|
||||
// The day names + their joined block are tracked so only the names (not the
|
||||
// commas/conjunction) can be italicised in the final string.
|
||||
val byDay = parts["BYDAY"]
|
||||
var dayNames: List<String>? = null
|
||||
var joinedDays: String? = null
|
||||
val main = if (freq == "WEEKLY" && !byDay.isNullOrBlank()) {
|
||||
val days = byDay.split(',').mapNotNull { rruleDayName(it.trim(), locale) }
|
||||
if (days.isNotEmpty()) {
|
||||
val joined = ListFormatter.getInstance(locale).format(days)
|
||||
dayNames = days
|
||||
joinedDays = joined
|
||||
stringResource(R.string.recurrence_on_days, base, joined)
|
||||
} else {
|
||||
base
|
||||
}
|
||||
} else {
|
||||
base
|
||||
}
|
||||
|
||||
// End bound: UNTIL (a date) takes precedence over COUNT (a number of times).
|
||||
val until = parts["UNTIL"]?.let { parseUntilDate(it, locale) }
|
||||
val count = parts["COUNT"]?.toIntOrNull()
|
||||
val full = when {
|
||||
until != null -> stringResource(R.string.recurrence_with_until, main, until)
|
||||
count != null -> stringResource(R.string.recurrence_with_count, main, count)
|
||||
else -> main
|
||||
}
|
||||
|
||||
return buildAnnotatedString {
|
||||
append(full)
|
||||
val names = dayNames
|
||||
val joined = joinedDays
|
||||
if (names != null && joined != null) {
|
||||
// Italicise each day name within the joined block only — leaving the
|
||||
// separators and conjunction ("und"/"and") in the regular style.
|
||||
val regionStart = full.indexOf(joined)
|
||||
if (regionStart >= 0) {
|
||||
val regionEnd = regionStart + joined.length
|
||||
var cursor = regionStart
|
||||
for (name in names) {
|
||||
val at = full.indexOf(name, cursor)
|
||||
if (at in regionStart until regionEnd) {
|
||||
addStyle(SpanStyle(fontStyle = FontStyle.Italic), at, at + name.length)
|
||||
cursor = at + name.length
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Map an RRULE BYDAY token (e.g. "TU" or "2TH") to a localized short weekday name. */
|
||||
private fun rruleDayName(token: String, locale: Locale): String? {
|
||||
val dow = when (token.takeLast(2).uppercase()) {
|
||||
"MO" -> DayOfWeek.MONDAY
|
||||
"TU" -> DayOfWeek.TUESDAY
|
||||
"WE" -> DayOfWeek.WEDNESDAY
|
||||
"TH" -> DayOfWeek.THURSDAY
|
||||
"FR" -> DayOfWeek.FRIDAY
|
||||
"SA" -> DayOfWeek.SATURDAY
|
||||
"SU" -> DayOfWeek.SUNDAY
|
||||
else -> return null
|
||||
}
|
||||
return dow.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||
}
|
||||
|
||||
/** Parse an RRULE UNTIL value ("20261231" or "20261231T235959Z") to a localized date. */
|
||||
private fun parseUntilDate(raw: String, locale: Locale): String? {
|
||||
val digits = raw.takeWhile { it.isDigit() }
|
||||
if (digits.length < 8) return null
|
||||
return try {
|
||||
val date = java.time.LocalDate.of(
|
||||
digits.substring(0, 4).toInt(),
|
||||
digits.substring(4, 6).toInt(),
|
||||
digits.substring(6, 8).toInt(),
|
||||
)
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an event's time into a primary line (date, or "All day") and an
|
||||
* optional secondary line (time range). Multi-day timed events collapse into a
|
||||
* single primary line spanning both ends.
|
||||
*/
|
||||
@Composable
|
||||
private fun formatWhen(
|
||||
instance: EventInstance,
|
||||
zone: TimeZone,
|
||||
locale: Locale,
|
||||
): Pair<String, String?> {
|
||||
val zid = ZoneId.of(zone.id)
|
||||
val dateFull = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(locale)
|
||||
val dateMedium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
val timeShort = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
|
||||
|
||||
val startLdt = instance.start.toJavaLocalDateTime(zid)
|
||||
val allDayLabel = stringResource(R.string.event_detail_all_day)
|
||||
|
||||
if (instance.isAllDay) {
|
||||
// All-day end is the exclusive next midnight; step back to the last
|
||||
// covered day so a one-day event reads as a single date.
|
||||
val lastLdt = (instance.end - 1.seconds).toJavaLocalDateTime(zid)
|
||||
return if (startLdt.toLocalDate() == lastLdt.toLocalDate()) {
|
||||
allDayLabel to dateFull.format(startLdt.toLocalDate())
|
||||
} else {
|
||||
allDayLabel to
|
||||
"${dateMedium.format(startLdt.toLocalDate())} – ${dateMedium.format(lastLdt.toLocalDate())}"
|
||||
}
|
||||
}
|
||||
|
||||
val endLdt = instance.end.toJavaLocalDateTime(zid)
|
||||
return if (startLdt.toLocalDate() == endLdt.toLocalDate()) {
|
||||
dateFull.format(startLdt.toLocalDate()) to
|
||||
"${timeShort.format(startLdt)} – ${timeShort.format(endLdt)}"
|
||||
} else {
|
||||
val start = "${dateMedium.format(startLdt.toLocalDate())} ${timeShort.format(startLdt)}"
|
||||
val end = "${dateMedium.format(endLdt.toLocalDate())} ${timeShort.format(endLdt)}"
|
||||
"$start – $end" to null
|
||||
}
|
||||
}
|
||||
|
||||
private fun Instant.toJavaLocalDateTime(zid: ZoneId): java.time.LocalDateTime =
|
||||
java.time.LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(toEpochMilliseconds()), zid)
|
||||
|
||||
/** Open a maps intent for [query]; fall back to a web maps URL if no app handles geo:. */
|
||||
private fun openInMaps(context: Context, query: String) {
|
||||
val encoded = Uri.encode(query)
|
||||
val geo = Intent(Intent.ACTION_VIEW, Uri.parse("geo:0,0?q=$encoded"))
|
||||
try {
|
||||
context.startActivity(geo)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
val web = Intent(
|
||||
Intent.ACTION_VIEW,
|
||||
Uri.parse("https://www.google.com/maps/search/?api=1&query=$encoded"),
|
||||
)
|
||||
try {
|
||||
context.startActivity(web)
|
||||
} catch (e2: ActivityNotFoundException) {
|
||||
// No browser either — nothing sensible to do; swallow.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package de.jeanlucmakiola.calendula.ui.detail
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
|
||||
/**
|
||||
* UI state for the event-detail bottom sheet (spec S4). Read-only in V1.
|
||||
*/
|
||||
sealed interface EventDetailUiState {
|
||||
data object Loading : EventDetailUiState
|
||||
data class Failure(val reason: FailureReason) : EventDetailUiState
|
||||
data class Success(
|
||||
val detail: EventDetail,
|
||||
/** Display name of the owning calendar, null if it can't be resolved. */
|
||||
val calendarName: String?,
|
||||
) : EventDetailUiState
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package de.jeanlucmakiola.calendula.ui.detail
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Loads a single event's detail on demand for the bottom sheet (spec S4).
|
||||
* The event id is set via [open]; the sheet observes [state].
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class EventDetailViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _target = MutableStateFlow<Target?>(null)
|
||||
// Bumped by retry() to re-run the load for the same target.
|
||||
private val _reload = MutableStateFlow(0)
|
||||
|
||||
val state: StateFlow<EventDetailUiState> =
|
||||
combine(_target, _reload) { target, _ -> target }
|
||||
.flatMapLatest { target ->
|
||||
if (target == null) {
|
||||
flowOf<EventDetailUiState>(EventDetailUiState.Loading)
|
||||
} else {
|
||||
flow {
|
||||
emit(EventDetailUiState.Loading)
|
||||
emit(loadDetail(target))
|
||||
}
|
||||
}
|
||||
}
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = EventDetailUiState.Loading,
|
||||
)
|
||||
|
||||
/**
|
||||
* Open (or switch) to the tapped occurrence. [beginMillis]/[endMillis] are
|
||||
* the occurrence's own times (from `CalendarContract.Instances`); they
|
||||
* override the series DTSTART/DTEND so recurring events show the correct
|
||||
* date instead of the first occurrence.
|
||||
*/
|
||||
fun open(eventId: Long, beginMillis: Long, endMillis: Long) {
|
||||
_target.value = Target(eventId, beginMillis, endMillis)
|
||||
}
|
||||
|
||||
/** Re-run the current load after a failure. */
|
||||
fun retry() {
|
||||
_reload.value += 1
|
||||
}
|
||||
|
||||
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
|
||||
val detail = repository.eventDetail(target.eventId)
|
||||
// The Events row holds the series start; replace it with this
|
||||
// occurrence's time so recurring events render correctly.
|
||||
val corrected = detail.copy(
|
||||
instance = detail.instance.copy(
|
||||
start = Instant.fromEpochMilliseconds(target.beginMillis),
|
||||
end = Instant.fromEpochMilliseconds(target.endMillis),
|
||||
),
|
||||
)
|
||||
val calendarName = repository.calendars().first()
|
||||
.firstOrNull { it.id == corrected.instance.calendarId }
|
||||
?.displayName
|
||||
EventDetailUiState.Success(corrected, calendarName)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: NoSuchEventException) {
|
||||
EventDetailUiState.Failure(FailureReason.EventNotFound)
|
||||
} catch (e: SecurityException) {
|
||||
EventDetailUiState.Failure(FailureReason.PermissionRevoked)
|
||||
} catch (e: Exception) {
|
||||
EventDetailUiState.Failure(FailureReason.ProviderUnavailable)
|
||||
}
|
||||
|
||||
/** A tapped occurrence: the series [eventId] plus this occurrence's own times. */
|
||||
private data class Target(val eventId: Long, val beginMillis: Long, val endMillis: Long)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package de.jeanlucmakiola.calendula.ui.filter
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
|
||||
/**
|
||||
* Calendar-visibility filter (M3), rendered inline in the navigation drawer.
|
||||
* Every calendar grouped by account, each with a colour swatch and a visibility
|
||||
* switch; toggling writes straight to DataStore and every calendar view
|
||||
* re-filters live. Three states (Loading / Failure / Success).
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarFilterList(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: FilterViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
when (val s = state) {
|
||||
FilterUiState.Loading -> FilterLoading(modifier)
|
||||
is FilterUiState.Failure -> FilterMessage(s.reason, modifier)
|
||||
is FilterUiState.Success -> FilterList(
|
||||
groups = s.groups,
|
||||
onSetVisible = viewModel::setVisible,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilterList(
|
||||
groups: List<AccountGroup>,
|
||||
onSetVisible: (Long, Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(vertical = 4.dp),
|
||||
) {
|
||||
groups.forEach { group ->
|
||||
item(key = "header-${group.account}") {
|
||||
Text(
|
||||
text = group.account,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
items(group.calendars, key = { it.id }) { cal ->
|
||||
CalendarToggleRow(
|
||||
row = cal,
|
||||
dark = dark,
|
||||
onCheckedChange = { onSetVisible(cal.id, it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CalendarToggleRow(
|
||||
row: CalendarRow,
|
||||
dark: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 28.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.background(pastelize(row.color, dark), CircleShape),
|
||||
)
|
||||
Text(
|
||||
text = row.displayName,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Checkbox(
|
||||
checked = row.visible,
|
||||
onCheckedChange = onCheckedChange,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilterLoading(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
repeat(4) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 28.dp)
|
||||
.fillMaxWidth()
|
||||
.height(36.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
MaterialTheme.shapes.medium,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilterMessage(reason: FailureReason, modifier: Modifier = Modifier) {
|
||||
val msg = when (reason) {
|
||||
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars
|
||||
FailureReason.PermissionRevoked -> R.string.state_failure_permission
|
||||
else -> R.string.state_failure_provider
|
||||
}
|
||||
Text(
|
||||
text = stringResource(msg),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 28.dp, vertical = 24.dp),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package de.jeanlucmakiola.calendula.ui.filter
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
|
||||
/**
|
||||
* State for the calendar-filter sheet (M3). The user toggles per-calendar
|
||||
* visibility; the choice is persisted app-side (separate from the system's
|
||||
* VISIBLE flag) and applied to every calendar view.
|
||||
*/
|
||||
sealed interface FilterUiState {
|
||||
data object Loading : FilterUiState
|
||||
data class Failure(val reason: FailureReason) : FilterUiState
|
||||
data class Success(val groups: List<AccountGroup>) : FilterUiState
|
||||
}
|
||||
|
||||
/** Calendars grouped under the account that owns them (Nextcloud / Local / …). */
|
||||
data class AccountGroup(
|
||||
val account: String,
|
||||
val calendars: List<CalendarRow>,
|
||||
)
|
||||
|
||||
data class CalendarRow(
|
||||
val id: Long,
|
||||
val displayName: String,
|
||||
val color: Int,
|
||||
val visible: Boolean,
|
||||
)
|
||||
@@ -0,0 +1,85 @@
|
||||
package de.jeanlucmakiola.calendula.ui.filter
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class FilterViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
private val prefs: CalendarPrefs,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
val state: StateFlow<FilterUiState> =
|
||||
combine(
|
||||
repository.calendars(),
|
||||
prefs.hiddenCalendarIds,
|
||||
) { calendars, hidden ->
|
||||
if (calendars.isEmpty()) {
|
||||
FilterUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
} else {
|
||||
FilterUiState.Success(groupByAccount(calendars, hidden))
|
||||
}
|
||||
}
|
||||
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = FilterUiState.Loading,
|
||||
)
|
||||
|
||||
/** Show or hide a single calendar; persists the new hidden set. */
|
||||
fun setVisible(calendarId: Long, visible: Boolean) {
|
||||
viewModelScope.launch {
|
||||
val current = prefs.hiddenCalendarIds.first()
|
||||
val next = if (visible) current - calendarId else current + calendarId
|
||||
if (next != current) prefs.setHiddenCalendarIds(next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Group calendars under their owning account, preserving the provider's order
|
||||
* within each group and ordering groups by first appearance. A calendar is
|
||||
* "visible" when its id is *not* in [hidden].
|
||||
*/
|
||||
internal fun groupByAccount(
|
||||
calendars: List<CalendarSource>,
|
||||
hidden: Set<Long>,
|
||||
): List<AccountGroup> =
|
||||
calendars
|
||||
.groupBy { it.accountLabel() }
|
||||
.map { (account, cals) ->
|
||||
AccountGroup(
|
||||
account = account,
|
||||
calendars = cals.map { c ->
|
||||
CalendarRow(
|
||||
id = c.id,
|
||||
displayName = c.displayName,
|
||||
color = c.color,
|
||||
visible = c.id !in hidden,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Account header text: the account name, falling back to its type. */
|
||||
private fun CalendarSource.accountLabel(): String =
|
||||
accountName.takeIf { it.isNotBlank() } ?: accountType.takeIf { it.isNotBlank() } ?: displayName
|
||||
@@ -0,0 +1,479 @@
|
||||
package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.YearMonth
|
||||
import kotlinx.datetime.plus
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MonthScreen(
|
||||
selectedView: CalendarView,
|
||||
onSelectView: (CalendarView) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: MonthViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val month by viewModel.month.collectAsStateWithLifecycle()
|
||||
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val isOnCurrentMonth = when (val s = state) {
|
||||
is MonthUiState.Success -> s.month == YearMonth(s.today.year, s.today.month)
|
||||
else -> true
|
||||
}
|
||||
|
||||
// Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide).
|
||||
var slideDir by remember { mutableIntStateOf(0) }
|
||||
|
||||
val goNext = {
|
||||
slideDir = 1
|
||||
viewModel.goToNext()
|
||||
}
|
||||
val goPrev = {
|
||||
slideDir = -1
|
||||
viewModel.goToPrev()
|
||||
}
|
||||
val jumpToToday = {
|
||||
slideDir = 0
|
||||
viewModel.goToToday()
|
||||
}
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
// Open only via the menu button — edge-swipe would fight the month swipe.
|
||||
gesturesEnabled = drawerState.isOpen,
|
||||
drawerContent = {
|
||||
CalendarDrawer(
|
||||
onToday = {
|
||||
jumpToToday()
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
onSettings = {
|
||||
onOpenSettings()
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
MonthTopBar(
|
||||
month = month,
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next()) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
AnimatedVisibility(
|
||||
visible = !isOnCurrentMonth,
|
||||
enter = scaleIn(),
|
||||
exit = scaleOut(),
|
||||
) {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = jumpToToday,
|
||||
icon = { Icon(Icons.Default.Refresh, contentDescription = null) },
|
||||
text = { Text(stringResource(R.string.month_today_action)) },
|
||||
)
|
||||
}
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
WeekdayHeader(weekStart = weekStart)
|
||||
MonthContent(
|
||||
state = state,
|
||||
weekStart = weekStart,
|
||||
slideDir = slideDir,
|
||||
onSwipeNext = goNext,
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
onOpenDay = onOpenDay,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MonthContent(
|
||||
state: MonthUiState,
|
||||
weekStart: DayOfWeek,
|
||||
slideDir: Int,
|
||||
onSwipeNext: () -> Unit,
|
||||
onSwipePrev: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val threshold = with(density) { 6.dp.toPx() }
|
||||
var dragAccum by remember { mutableFloatStateOf(0f) }
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
|
||||
val swipeModifier = Modifier.pointerInput(Unit) {
|
||||
detectHorizontalDragGestures(
|
||||
onDragStart = { dragAccum = 0f },
|
||||
onDragEnd = {
|
||||
when {
|
||||
dragAccum < -threshold -> onSwipeNext()
|
||||
dragAccum > threshold -> onSwipePrev()
|
||||
}
|
||||
dragAccum = 0f
|
||||
},
|
||||
onDragCancel = { dragAccum = 0f },
|
||||
onHorizontalDrag = { _, drag -> dragAccum += drag },
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedContent(
|
||||
targetState = state,
|
||||
modifier = Modifier.fillMaxSize().then(swipeModifier),
|
||||
contentKey = { s ->
|
||||
when (s) {
|
||||
is MonthUiState.Success -> "success-${s.month}"
|
||||
is MonthUiState.Failure -> "failure-${s.reason}"
|
||||
MonthUiState.Loading -> "loading"
|
||||
}
|
||||
},
|
||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
|
||||
label = "month-transition",
|
||||
) { s ->
|
||||
when (s) {
|
||||
MonthUiState.Loading -> MonthGridLoading()
|
||||
is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||
is MonthUiState.Success -> MonthGrid(
|
||||
state = s,
|
||||
weekStart = weekStart,
|
||||
onOpenDay = onOpenDay,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun MonthTopBar(
|
||||
month: YearMonth,
|
||||
selectedView: CalendarView,
|
||||
onCycleView: () -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||
) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = formatMonthYear(month),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onOpenDrawer) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Menu,
|
||||
contentDescription = stringResource(R.string.month_open_menu),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
ViewSwitcherPill(
|
||||
current = selectedView,
|
||||
onCycle = onCycleView,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
},
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekdayHeader(weekStart: DayOfWeek) {
|
||||
val locale = currentLocale()
|
||||
val days = remember(weekStart, locale) {
|
||||
(0 until 7).map { offset ->
|
||||
DayOfWeek.entries[((weekStart.ordinal + offset) % 7)]
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
days.forEach { dow ->
|
||||
val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY
|
||||
val javaDow = java.time.DayOfWeek.of(dow.ordinal + 1)
|
||||
Text(
|
||||
text = javaDow.getDisplayName(JavaTextStyle.SHORT, locale),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (isWeekend) MaterialTheme.colorScheme.onSurfaceVariant
|
||||
else MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MonthGrid(
|
||||
state: MonthUiState.Success,
|
||||
weekStart: DayOfWeek,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
) {
|
||||
val firstOfMonth = LocalDate(state.month.year, state.month.month, 1)
|
||||
val gridStart = firstOfMonth.startOfGridWeek(weekStart)
|
||||
|
||||
// Show only the weeks the current month actually touches; leading/trailing
|
||||
// days of neighbouring months are left blank rather than rendered.
|
||||
val leadOffset = ((firstOfMonth.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
||||
val daysInMonth =
|
||||
java.time.YearMonth.of(state.month.year, state.month.month.ordinal + 1).lengthOfMonth()
|
||||
val weeks = (leadOffset + daysInMonth + 6) / 7
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
repeat(weeks) { row ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
repeat(7) { col ->
|
||||
val date = gridStart.plus(row * 7 + col, DateTimeUnit.DAY)
|
||||
val inMonth =
|
||||
date.month == state.month.month && date.year == state.month.year
|
||||
if (inMonth) {
|
||||
DayCard(
|
||||
date = date,
|
||||
isToday = date == state.today,
|
||||
data = state.cells[date],
|
||||
onClick = { onOpenDay(date) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
} else {
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun DayCard(
|
||||
date: LocalDate,
|
||||
isToday: Boolean,
|
||||
data: DayCellData?,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val todayPrefix = stringResource(R.string.month_a11y_today_prefix)
|
||||
val cellLabel = buildString {
|
||||
if (isToday) append(todayPrefix).append(", ")
|
||||
append(date.year).append('-')
|
||||
append((date.month.ordinal + 1).toString().padStart(2, '0')).append('-')
|
||||
append(date.day.toString().padStart(2, '0'))
|
||||
data?.let { append(", ").append(it.count).append(" Events") }
|
||||
}
|
||||
|
||||
// M3 Expressive press feedback: a spatial spring from the active motion
|
||||
// scheme drives a subtle scale, instead of a fixed easing curve.
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val pressed by interactionSource.collectIsPressedAsState()
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (pressed) 0.94f else 1f,
|
||||
animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
|
||||
label = "day-card-press",
|
||||
)
|
||||
|
||||
Card(
|
||||
onClick = onClick,
|
||||
interactionSource = interactionSource,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isToday) MaterialTheme.colorScheme.primaryContainer
|
||||
else MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
contentColor = if (isToday) MaterialTheme.colorScheme.onPrimaryContainer
|
||||
else MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}
|
||||
.semantics { contentDescription = cellLabel },
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = 4.dp, bottom = 2.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = date.day.toString(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
EventDotRow(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventDotRow(data: DayCellData?) {
|
||||
if (data == null || data.swatches.isEmpty()) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
return
|
||||
}
|
||||
val dark = isSystemInDarkTheme()
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
data.swatches.forEach { argb ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(6.dp)
|
||||
.background(pastelize(argb, dark), CircleShape),
|
||||
)
|
||||
}
|
||||
if (data.count > data.swatches.size) {
|
||||
Text(
|
||||
text = "+${data.count - data.swatches.size}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MonthGridLoading() {
|
||||
val shape = MaterialTheme.shapes.medium
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
repeat(6) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
repeat(7) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
shape = shape,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatMonthYear(ym: YearMonth): String {
|
||||
val locale = Locale.getDefault()
|
||||
val name = java.time.Month.of(ym.month.ordinal + 1)
|
||||
.getDisplayName(JavaTextStyle.FULL, locale)
|
||||
return "$name ${ym.year}"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.YearMonth
|
||||
|
||||
/**
|
||||
* Per-day aggregation surfaced to the month grid. We only need
|
||||
* - the total event count (drives the optional "+N" indicator), and
|
||||
* - up to three calendar colors for the dot row.
|
||||
*
|
||||
* The day cell never holds full event objects — the detail sheet pulls those
|
||||
* lazily.
|
||||
*/
|
||||
data class DayCellData(
|
||||
val count: Int,
|
||||
val swatches: List<Int>,
|
||||
)
|
||||
|
||||
sealed interface MonthUiState {
|
||||
data object Loading : MonthUiState
|
||||
data class Failure(val reason: FailureReason) : MonthUiState
|
||||
data class Success(
|
||||
val month: YearMonth,
|
||||
val today: LocalDate,
|
||||
val cells: Map<LocalDate, DayCellData>,
|
||||
) : MonthUiState
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.YearMonth
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.minus
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import java.util.Locale
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class MonthViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
settingsPrefs: SettingsPrefs,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
private val zone = TimeZone.currentSystemDefault()
|
||||
private val locale: Locale = Locale.getDefault()
|
||||
|
||||
/** First day of the week, from the Settings preference (AUTO → locale). */
|
||||
val weekStart: StateFlow<DayOfWeek> = settingsPrefs.weekStart
|
||||
.map { it.resolveFirstDay(locale) }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = DayOfWeek.MONDAY,
|
||||
)
|
||||
|
||||
private val todayDate: LocalDate
|
||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||
|
||||
private val _month = MutableStateFlow(YearMonth(todayDate.year, todayDate.month))
|
||||
val month: StateFlow<YearMonth> = _month
|
||||
|
||||
val state: StateFlow<MonthUiState> =
|
||||
combine(_month, weekStart) { ym, ws -> ym to ws }
|
||||
.flatMapLatest { (ym, ws) ->
|
||||
val range = monthGridRange(ym, ws, zone)
|
||||
combine(
|
||||
repository.calendars(),
|
||||
repository.instances(range),
|
||||
) { calendars, instances ->
|
||||
buildState(ym, calendars, instances)
|
||||
}
|
||||
}
|
||||
.catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = MonthUiState.Loading,
|
||||
)
|
||||
|
||||
fun goToPrev() {
|
||||
_month.value = _month.value.minus(1, DateTimeUnit.MONTH)
|
||||
}
|
||||
|
||||
fun goToNext() {
|
||||
_month.value = _month.value.plus(1, DateTimeUnit.MONTH)
|
||||
}
|
||||
|
||||
fun goToToday() {
|
||||
_month.value = YearMonth(todayDate.year, todayDate.month)
|
||||
}
|
||||
|
||||
private fun buildState(
|
||||
ym: YearMonth,
|
||||
calendars: List<CalendarSource>,
|
||||
instances: List<EventInstance>,
|
||||
): MonthUiState {
|
||||
if (calendars.isEmpty()) {
|
||||
return MonthUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
}
|
||||
val byDay = instances.groupBy { it.start.toLocalDateTime(zone).date }
|
||||
.mapValues { (_, evs) ->
|
||||
DayCellData(
|
||||
count = evs.size,
|
||||
swatches = evs.map { it.color }.distinct().take(3),
|
||||
)
|
||||
}
|
||||
return MonthUiState.Success(
|
||||
month = ym,
|
||||
today = todayDate,
|
||||
cells = byDay,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The on-screen grid spans 6 weeks anchored on [weekStart]. Includes the
|
||||
* trailing days of the previous month and the leading days of the next month.
|
||||
*/
|
||||
internal fun monthGridRange(
|
||||
ym: YearMonth,
|
||||
weekStart: DayOfWeek,
|
||||
zone: TimeZone,
|
||||
): ClosedRange<Instant> {
|
||||
val firstOfMonth = LocalDate(ym.year, ym.month, 1)
|
||||
val gridStart = firstOfMonth.startOfGridWeek(weekStart)
|
||||
val gridEnd = gridStart.plus(41, DateTimeUnit.DAY)
|
||||
val start = gridStart.atStartOfDayIn(zone)
|
||||
val end = gridEnd.atTime(23, 59, 59).toInstant(zone)
|
||||
return start..end
|
||||
}
|
||||
|
||||
internal fun LocalDate.startOfGridWeek(weekStart: DayOfWeek): LocalDate {
|
||||
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
|
||||
val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
||||
return minus(offset, DateTimeUnit.DAY)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.os.LocaleListCompat
|
||||
|
||||
/** UI-facing language choice. AUTO follows the system languages. */
|
||||
enum class LanguagePref { AUTO, GERMAN, ENGLISH }
|
||||
|
||||
/**
|
||||
* Per-app language via AppCompatDelegate. On API 33+ this delegates to the
|
||||
* platform per-app-languages API; below that the appcompat backport persists
|
||||
* the choice itself (manifest `autoStoreLocales` service), so we don't mirror
|
||||
* it in DataStore. Setting a locale recreates the activity, which re-reads the
|
||||
* current value for the dropdown.
|
||||
*/
|
||||
object AppLanguage {
|
||||
|
||||
fun current(): LanguagePref {
|
||||
val locales = AppCompatDelegate.getApplicationLocales()
|
||||
if (locales.isEmpty) return LanguagePref.AUTO
|
||||
return when (locales[0]?.language) {
|
||||
"de" -> LanguagePref.GERMAN
|
||||
"en" -> LanguagePref.ENGLISH
|
||||
else -> LanguagePref.AUTO
|
||||
}
|
||||
}
|
||||
|
||||
fun apply(pref: LanguagePref) {
|
||||
val locales = when (pref) {
|
||||
LanguagePref.AUTO -> LocaleListCompat.getEmptyLocaleList()
|
||||
LanguagePref.GERMAN -> LocaleListCompat.forLanguageTags("de")
|
||||
LanguagePref.ENGLISH -> LocaleListCompat.forLanguageTags("en")
|
||||
}
|
||||
AppCompatDelegate.setApplicationLocales(locales)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.core.net.toUri
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
|
||||
/**
|
||||
* Settings (M4) — appearance (theme, dynamic colour, week start), language,
|
||||
* and an about section. A full-screen destination; [onBack] pops it.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: SettingsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
// Intercept the system back button/gesture — without this it falls through
|
||||
// to the activity and closes the app instead of returning to the calendar.
|
||||
BackHandler { onBack() }
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.settings_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.settings_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
SectionHeader(stringResource(R.string.settings_section_appearance))
|
||||
|
||||
SettingDropdownRow(
|
||||
title = stringResource(R.string.settings_theme),
|
||||
selected = state.themeMode,
|
||||
options = ThemeMode.entries,
|
||||
optionLabel = { themeLabel(it) },
|
||||
onSelect = viewModel::setThemeMode,
|
||||
)
|
||||
DynamicColorRow(
|
||||
checked = state.dynamicColor,
|
||||
enabled = state.dynamicColorAvailable,
|
||||
onCheckedChange = viewModel::setDynamicColor,
|
||||
)
|
||||
SettingDropdownRow(
|
||||
title = stringResource(R.string.settings_week_start),
|
||||
selected = state.weekStart,
|
||||
options = WeekStartPref.entries,
|
||||
optionLabel = { weekStartLabel(it) },
|
||||
onSelect = viewModel::setWeekStart,
|
||||
)
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
SectionHeader(stringResource(R.string.settings_section_language))
|
||||
LanguageRow()
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
SectionHeader(stringResource(R.string.settings_section_about))
|
||||
AboutSection()
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LanguageRow() {
|
||||
// Setting a locale recreates the activity; mirror the choice locally so the
|
||||
// dropdown updates instantly even before the recreation lands.
|
||||
var current by remember { mutableStateOf(AppLanguage.current()) }
|
||||
SettingDropdownRow(
|
||||
title = stringResource(R.string.settings_language),
|
||||
selected = current,
|
||||
options = LanguagePref.entries,
|
||||
optionLabel = { languageLabel(it) },
|
||||
onSelect = {
|
||||
current = it
|
||||
AppLanguage.apply(it)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun <T> SettingDropdownRow(
|
||||
title: String,
|
||||
selected: T,
|
||||
options: List<T>,
|
||||
optionLabel: @Composable (T) -> String,
|
||||
onSelect: (T) -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expanded = true }
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = optionLabel(selected),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Icon(
|
||||
Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
options.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(optionLabel(option)) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onSelect(option)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DynamicColorRow(
|
||||
checked: Boolean,
|
||||
enabled: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_dynamic_color),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (enabled) MaterialTheme.colorScheme.onSurface
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (!enabled) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_dynamic_color_unavailable),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
enabled = enabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AboutSection() {
|
||||
val context = LocalContext.current
|
||||
val versionName = remember {
|
||||
runCatching {
|
||||
context.packageManager.getPackageInfo(context.packageName, 0).versionName
|
||||
}.getOrNull() ?: "—"
|
||||
}
|
||||
val sourceUrl = stringResource(R.string.about_source_url)
|
||||
|
||||
AboutRow(
|
||||
title = stringResource(R.string.settings_version),
|
||||
value = versionName,
|
||||
)
|
||||
AboutRow(
|
||||
title = stringResource(R.string.settings_license),
|
||||
value = stringResource(R.string.settings_license_value),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f).padding(start = 8.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_source),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Text(
|
||||
text = sourceUrl.removePrefix("https://"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
TextButton(onClick = {
|
||||
val intent = Intent(Intent.ACTION_VIEW, sourceUrl.toUri())
|
||||
runCatching { context.startActivity(intent) }
|
||||
}) {
|
||||
Text(stringResource(R.string.settings_source_open))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AboutRow(title: String, value: String) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun themeLabel(mode: ThemeMode): String = stringResource(
|
||||
when (mode) {
|
||||
ThemeMode.SYSTEM -> R.string.settings_theme_system
|
||||
ThemeMode.LIGHT -> R.string.settings_theme_light
|
||||
ThemeMode.DARK -> R.string.settings_theme_dark
|
||||
},
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun weekStartLabel(pref: WeekStartPref): String = stringResource(
|
||||
when (pref) {
|
||||
WeekStartPref.AUTO -> R.string.settings_week_start_auto
|
||||
WeekStartPref.MONDAY -> R.string.settings_week_start_monday
|
||||
WeekStartPref.SUNDAY -> R.string.settings_week_start_sunday
|
||||
},
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun languageLabel(pref: LanguagePref): String = stringResource(
|
||||
when (pref) {
|
||||
LanguagePref.AUTO -> R.string.settings_language_auto
|
||||
LanguagePref.GERMAN -> R.string.settings_language_german
|
||||
LanguagePref.ENGLISH -> R.string.settings_language_english
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
|
||||
/**
|
||||
* Settings screen state (M4). Persisted preferences are instant to read, so
|
||||
* there is no Loading/Failure here — only a populated Success snapshot.
|
||||
* [dynamicColorAvailable] is false below Android 12, where the toggle is shown
|
||||
* disabled.
|
||||
*/
|
||||
data class SettingsUiState(
|
||||
val themeMode: ThemeMode = ThemeMode.SYSTEM,
|
||||
val dynamicColor: Boolean = true,
|
||||
val dynamicColorAvailable: Boolean = true,
|
||||
val weekStart: WeekStartPref = WeekStartPref.AUTO,
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import android.os.Build
|
||||
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.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val prefs: SettingsPrefs,
|
||||
) : ViewModel() {
|
||||
|
||||
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
|
||||
val state: StateFlow<SettingsUiState> =
|
||||
combine(
|
||||
prefs.themeMode,
|
||||
prefs.dynamicColor,
|
||||
prefs.weekStart,
|
||||
) { theme, dynamic, weekStart ->
|
||||
SettingsUiState(
|
||||
themeMode = theme,
|
||||
dynamicColor = dynamic && dynamicColorAvailable,
|
||||
dynamicColorAvailable = dynamicColorAvailable,
|
||||
weekStart = weekStart,
|
||||
)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
|
||||
)
|
||||
|
||||
fun setThemeMode(mode: ThemeMode) {
|
||||
viewModelScope.launch { prefs.setThemeMode(mode) }
|
||||
}
|
||||
|
||||
fun setDynamicColor(enabled: Boolean) {
|
||||
viewModelScope.launch { prefs.setDynamicColor(enabled) }
|
||||
}
|
||||
|
||||
fun setWeekStart(pref: WeekStartPref) {
|
||||
viewModelScope.launch { prefs.setWeekStart(pref) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,723 @@
|
||||
package de.jeanlucmakiola.calendula.ui.week
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.plus
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val HOUR_HEIGHT = 56.dp
|
||||
private val GUTTER_WIDTH = 48.dp
|
||||
private val MIN_EVENT_HEIGHT = 24.dp
|
||||
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
||||
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||
|
||||
/** Total all-day strip height for a week (0 when there are no all-day events). */
|
||||
private fun WeekUiState.Success.allDayStripHeight(): Dp {
|
||||
if (allDaySpans.isEmpty()) return 0.dp
|
||||
val lanes = allDaySpans.maxOf { it.lane } + 1
|
||||
return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun WeekScreen(
|
||||
selectedView: CalendarView,
|
||||
onSelectView: (CalendarView) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: WeekViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val weekStart by viewModel.weekStartDate.collectAsStateWithLifecycle()
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// The static header + all-day strip share the app bar's scrolled colour so
|
||||
// the whole top region elevates together once the timeline scrolls under it.
|
||||
val topSectionColor by animateColorAsState(
|
||||
targetValue = if (scrollBehavior.state.overlappedFraction > 0.01f) {
|
||||
MaterialTheme.colorScheme.surfaceContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
},
|
||||
label = "week-top-section-color",
|
||||
)
|
||||
|
||||
val isOnCurrentWeek = when (val s = state) {
|
||||
// True when today falls inside the displayed week — independent of which
|
||||
// weekday the user picked as the first day.
|
||||
is WeekUiState.Success ->
|
||||
s.today >= s.weekStart && s.today <= s.weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
|
||||
else -> true
|
||||
}
|
||||
|
||||
// Slide direction for the week transition: +1 = next, -1 = prev, 0 = jump.
|
||||
var slideDir by remember { mutableIntStateOf(0) }
|
||||
val goNext = { slideDir = 1; viewModel.goToNext() }
|
||||
val goPrev = { slideDir = -1; viewModel.goToPrev() }
|
||||
val jumpToToday = { slideDir = 0; viewModel.goToToday() }
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
// Open only via the menu button — edge-swipe would fight the week swipe.
|
||||
gesturesEnabled = drawerState.isOpen,
|
||||
drawerContent = {
|
||||
CalendarDrawer(
|
||||
onToday = { jumpToToday(); scope.launch { drawerState.close() } },
|
||||
onSettings = {
|
||||
onOpenSettings()
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
WeekTopBar(
|
||||
weekStart = weekStart,
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next()) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
AnimatedVisibility(
|
||||
visible = !isOnCurrentWeek,
|
||||
enter = scaleIn(),
|
||||
exit = scaleOut(),
|
||||
) {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = jumpToToday,
|
||||
icon = { Icon(Icons.Default.Refresh, contentDescription = null) },
|
||||
text = { Text(stringResource(R.string.week_today_action)) },
|
||||
)
|
||||
}
|
||||
},
|
||||
) { innerPadding ->
|
||||
WeekContent(
|
||||
state = state,
|
||||
slideDir = slideDir,
|
||||
topSectionColor = topSectionColor,
|
||||
onSwipeNext = goNext,
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
onEventClick = onEventClick,
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekContent(
|
||||
state: WeekUiState,
|
||||
slideDir: Int,
|
||||
topSectionColor: Color,
|
||||
onSwipeNext: () -> Unit,
|
||||
onSwipePrev: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val threshold = with(density) { 24.dp.toPx() }
|
||||
var dragAccum by remember { mutableFloatStateOf(0f) }
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
|
||||
// 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
|
||||
// noon once, on first entry into the week view (i.e. when arriving from the
|
||||
// month/day view), not on every swipe.
|
||||
val scrollState = rememberScrollState()
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||
val maxV = scrollState.maxValue
|
||||
val target = with(density) {
|
||||
(HOUR_HEIGHT.toPx() * 12 - (HOUR_HEIGHT.toPx() * 24 - maxV) / 2f).roundToInt()
|
||||
}.coerceIn(0, maxV)
|
||||
scrollState.scrollTo(target)
|
||||
}
|
||||
|
||||
// Single, hoisted all-day strip height — shared by the outgoing and incoming
|
||||
// week during a swipe, so the strip slides along but never jumps in height;
|
||||
// it just springs smoothly from the old to the new size.
|
||||
val targetAllDayHeight = (state as? WeekUiState.Success)?.allDayStripHeight() ?: 0.dp
|
||||
val allDayHeight by animateDpAsState(
|
||||
targetValue = targetAllDayHeight,
|
||||
label = "all-day-strip-height",
|
||||
)
|
||||
|
||||
// Whole-page horizontal swipe. It sits one level above the timeline's
|
||||
// vertical scroll: a horizontal drag only crosses *this* detector's slop,
|
||||
// while a vertical drag is consumed by the inner scroll first — so the two
|
||||
// gestures coexist without fighting.
|
||||
val swipeModifier = Modifier.pointerInput(Unit) {
|
||||
detectHorizontalDragGestures(
|
||||
onDragStart = { dragAccum = 0f },
|
||||
onDragEnd = {
|
||||
when {
|
||||
dragAccum < -threshold -> onSwipeNext()
|
||||
dragAccum > threshold -> onSwipePrev()
|
||||
}
|
||||
dragAccum = 0f
|
||||
},
|
||||
onDragCancel = { dragAccum = 0f },
|
||||
onHorizontalDrag = { _, drag -> dragAccum += drag },
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedContent(
|
||||
targetState = state,
|
||||
modifier = modifier.then(swipeModifier),
|
||||
contentKey = { s ->
|
||||
when (s) {
|
||||
is WeekUiState.Success -> "success-${s.weekStart}"
|
||||
is WeekUiState.Failure -> "failure-${s.reason}"
|
||||
WeekUiState.Loading -> "loading"
|
||||
}
|
||||
},
|
||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
|
||||
label = "week-transition",
|
||||
) { s ->
|
||||
when (s) {
|
||||
WeekUiState.Loading -> WeekLoading()
|
||||
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||
is WeekUiState.Success -> WeekSuccess(
|
||||
state = s,
|
||||
topSectionColor = topSectionColor,
|
||||
scrollState = scrollState,
|
||||
allDayHeight = allDayHeight,
|
||||
onEventClick = onEventClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekSuccess(
|
||||
state: WeekUiState.Success,
|
||||
topSectionColor: Color,
|
||||
scrollState: ScrollState,
|
||||
allDayHeight: Dp,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(topSectionColor),
|
||||
) {
|
||||
WeekDayHeader(days = state.days, today = state.today)
|
||||
AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick)
|
||||
}
|
||||
// Breathing room between the (colour-shifting) top section and the
|
||||
// scrolling timeline below.
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Timeline(state = state, scrollState = scrollState, onEventClick = onEventClick)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun WeekTopBar(
|
||||
weekStart: LocalDate,
|
||||
selectedView: CalendarView,
|
||||
onCycleView: () -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||
) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = formatWeekRange(weekStart),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onOpenDrawer) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Menu,
|
||||
contentDescription = stringResource(R.string.month_open_menu),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
ViewSwitcherPill(
|
||||
current = selectedView,
|
||||
onCycle = onCycleView,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
},
|
||||
// Match the static top section exactly: plain surface, lifting to
|
||||
// surfaceContainer once content scrolls under the bar.
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekDayHeader(days: List<LocalDate>, today: LocalDate) {
|
||||
val locale = currentLocale()
|
||||
val weekStart = days.first()
|
||||
val weekNumber = remember(weekStart) {
|
||||
java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, weekStart.day)
|
||||
.get(java.time.temporal.IsoFields.WEEK_OF_WEEK_BASED_YEAR)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp, bottom = 8.dp),
|
||||
) {
|
||||
// Mirror the day-column layout (empty weekday line + spacer) so the
|
||||
// badge lines up vertically with the date numbers.
|
||||
Column(
|
||||
modifier = Modifier.width(GUTTER_WIDTH),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(text = " ", style = MaterialTheme.typography.labelSmall)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
WeekNumberBadge(weekNumber = weekNumber)
|
||||
}
|
||||
days.forEach { date ->
|
||||
val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1)
|
||||
val isToday = date == today
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = javaDow.getDisplayName(JavaTextStyle.SHORT, locale),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
// Always reserve the 28dp circle slot so the header height is
|
||||
// identical whether or not the week contains today.
|
||||
Box(
|
||||
modifier = Modifier.size(28.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (isToday) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = date.day.toString(),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = date.day.toString(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Calendar-week badge shown in the header gutter, deliberately set apart with a
|
||||
* filled box and bold number. */
|
||||
@Composable
|
||||
private fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) {
|
||||
val label = stringResource(R.string.week_number_label)
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = modifier.semantics { contentDescription = "$label $weekNumber" },
|
||||
) {
|
||||
Text(
|
||||
text = weekNumber.toString(),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AllDayStrip(
|
||||
state: WeekUiState.Success,
|
||||
height: Dp,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
// Height is hoisted + animated so it slides and resizes smoothly;
|
||||
// padding sits inside it so the content area is lanes * row height.
|
||||
.height(height)
|
||||
.padding(vertical = ALL_DAY_VERTICAL_PADDING),
|
||||
) {
|
||||
// Keep the gutter-width offset so the bars line up with the day columns.
|
||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||
// Span bars are positioned absolutely so a multi-day event is one
|
||||
// connected bar across columns rather than a chip per day. clipToBounds
|
||||
// keeps bars from spilling out while the height animates.
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.clipToBounds(),
|
||||
) {
|
||||
val colWidth = maxWidth / 7
|
||||
state.allDaySpans.forEach { span ->
|
||||
val spanCols = span.endCol - span.startCol + 1
|
||||
AllDayBar(
|
||||
event = span.event,
|
||||
dark = dark,
|
||||
onClick = { onEventClick(span.event) },
|
||||
modifier = Modifier
|
||||
.offset(
|
||||
x = colWidth * span.startCol,
|
||||
y = ALL_DAY_ROW_HEIGHT * span.lane,
|
||||
)
|
||||
.width(colWidth * spanCols)
|
||||
.height(ALL_DAY_ROW_HEIGHT)
|
||||
.padding(horizontal = 1.dp, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AllDayBar(
|
||||
event: EventInstance,
|
||||
dark: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
.semantics { contentDescription = title },
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = Color.Black.copy(alpha = 0.8f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Timeline(
|
||||
state: WeekUiState.Success,
|
||||
scrollState: ScrollState,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
) {
|
||||
val totalHeight = HOUR_HEIGHT * 24
|
||||
val dark = isSystemInDarkTheme()
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// Gutter and day columns are two scroll viewports that SHARE one scroll
|
||||
// state, so they stay perfectly aligned. The day-column viewport is a
|
||||
// static, rounded-clipped window — the content scrolls inside it, so the
|
||||
// soft corners are permanent at any scroll position (not just at the
|
||||
// day's start/end).
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
// Hour gutter (scrolls in sync with the day columns)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(GUTTER_WIDTH)
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
(0 until 24).forEach { h ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(HOUR_HEIGHT),
|
||||
) {
|
||||
if (h > 0) {
|
||||
Text(
|
||||
text = "%02d".format(h),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.offset(y = (-6).dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Day columns: rounded, clipped scroll viewport (permanent corners).
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(totalHeight),
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
state.days.forEach { day ->
|
||||
DayColumnCard(
|
||||
blocks = state.timedByDay[day].orEmpty(),
|
||||
dark = dark,
|
||||
onEventClick = onEventClick,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DayColumnCard(
|
||||
blocks: List<TimedBlock>,
|
||||
dark: Boolean,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(
|
||||
// Plain rectangular columns — the soft corners come from the outer
|
||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||
shape = RectangleShape,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
modifier = modifier,
|
||||
) {
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
val colWidth = maxWidth
|
||||
blocks.forEach { block ->
|
||||
val laneWidth = colWidth / block.laneCount
|
||||
val top = HOUR_HEIGHT * (block.startMin / 60f)
|
||||
val rawHeight = HOUR_HEIGHT * ((block.endMin - block.startMin) / 60f)
|
||||
val height = if (rawHeight < MIN_EVENT_HEIGHT) MIN_EVENT_HEIGHT else rawHeight
|
||||
EventBlock(
|
||||
block = block,
|
||||
dark = dark,
|
||||
onClick = { onEventClick(block.event) },
|
||||
modifier = Modifier
|
||||
.offset(x = laneWidth * block.lane, y = top)
|
||||
.width(laneWidth)
|
||||
.height(height)
|
||||
.padding(horizontal = 1.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventBlock(
|
||||
block: TimedBlock,
|
||||
dark: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
val timeLabel = "${minToHm(block.startMin)}–${minToHm(block.endMin)}"
|
||||
val showTime = block.endMin - block.startMin >= 45
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||
.semantics { contentDescription = "$title, $timeLabel" },
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = if (showTime) 1 else 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = Color.Black.copy(alpha = 0.85f),
|
||||
)
|
||||
if (showTime) {
|
||||
// Narrow columns can't fit "13:00–14:00" on one line, so let it
|
||||
// wrap to a second line (after the dash) instead of clipping the
|
||||
// end time.
|
||||
Text(
|
||||
text = timeLabel,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = Color.Black.copy(alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekLoading() {
|
||||
val totalHeight = HOUR_HEIGHT * 24
|
||||
val scrollState = rememberScrollState()
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Header skeleton
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||
repeat(7) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 2.dp)
|
||||
.height(36.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceContainer,
|
||||
RoundedCornerShape(8.dp),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
|
||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||
repeat(7) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(totalHeight)
|
||||
.padding(horizontal = 2.dp)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun minToHm(min: Int): String =
|
||||
if (min >= MINUTES_PER_DAY) "24:00" else "%02d:%02d".format(min / 60, min % 60)
|
||||
|
||||
private fun formatWeekRange(weekStart: LocalDate): String {
|
||||
val locale = Locale.getDefault()
|
||||
val end = weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
|
||||
val monthName = { d: LocalDate ->
|
||||
java.time.Month.of(d.month.ordinal + 1).getDisplayName(JavaTextStyle.SHORT, locale)
|
||||
}
|
||||
return if (weekStart.month == end.month && weekStart.year == end.year) {
|
||||
"${weekStart.day}.–${end.day}. ${monthName(weekStart)} ${weekStart.year}"
|
||||
} else if (weekStart.year == end.year) {
|
||||
"${weekStart.day}. ${monthName(weekStart)} – ${end.day}. ${monthName(end)} ${end.year}"
|
||||
} else {
|
||||
"${weekStart.day}. ${monthName(weekStart)} ${weekStart.year} – " +
|
||||
"${end.day}. ${monthName(end)} ${end.year}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package de.jeanlucmakiola.calendula.ui.week
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* One timed event clipped to a single day and assigned a horizontal lane so
|
||||
* overlapping events render side-by-side (spec S2: "Overlap-Events nebeneinander
|
||||
* aufgelöst").
|
||||
*
|
||||
* @param startMin minutes from this day's midnight, clamped to [0, 1440]
|
||||
* @param endMin minutes from this day's midnight, clamped to [startMin, 1440];
|
||||
* equal to [startMin] for instant events (render enforces a
|
||||
* minimum tap-target height)
|
||||
* @param lane 0-based column within [laneCount]
|
||||
* @param laneCount number of columns the event's overlap-cluster needs
|
||||
*/
|
||||
data class TimedBlock(
|
||||
val event: EventInstance,
|
||||
val startMin: Int,
|
||||
val endMin: Int,
|
||||
val lane: Int,
|
||||
val laneCount: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* An all-day (or multi-day) event laid out as a single horizontal bar spanning
|
||||
* [startCol]..[endCol] of the visible week, stacked on row [lane] so overlapping
|
||||
* spans don't collide. A multi-day event is one connected bar — not one chip per
|
||||
* day.
|
||||
*
|
||||
* @param startCol first visible covered column, 0..6 (clamped to the week)
|
||||
* @param endCol last visible covered column, 0..6, inclusive
|
||||
* @param lane 0-based stacking row
|
||||
*/
|
||||
data class AllDaySpan(
|
||||
val event: EventInstance,
|
||||
val startCol: Int,
|
||||
val endCol: Int,
|
||||
val lane: Int,
|
||||
)
|
||||
|
||||
sealed interface WeekUiState {
|
||||
data object Loading : WeekUiState
|
||||
data class Failure(val reason: FailureReason) : WeekUiState
|
||||
data class Success(
|
||||
val weekStart: LocalDate,
|
||||
val today: LocalDate,
|
||||
/** The seven days of the week, [weekStart] first. */
|
||||
val days: List<LocalDate>,
|
||||
/** All-day/multi-day events as connected horizontal spans. */
|
||||
val allDaySpans: List<AllDaySpan>,
|
||||
/** Timed events, clipped to each day with lanes resolved. */
|
||||
val timedByDay: Map<LocalDate, List<TimedBlock>>,
|
||||
) : WeekUiState
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package de.jeanlucmakiola.calendula.ui.week
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.minus
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import java.util.Locale
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
const val MINUTES_PER_DAY: Int = 24 * 60
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class WeekViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
settingsPrefs: SettingsPrefs,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
private val zone = TimeZone.currentSystemDefault()
|
||||
private val locale: Locale = Locale.getDefault()
|
||||
|
||||
private val todayDate: LocalDate
|
||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||
|
||||
/** First day of the week, from the Settings preference (AUTO → locale). */
|
||||
private val weekStart: StateFlow<DayOfWeek> = settingsPrefs.weekStart
|
||||
.map { it.resolveFirstDay(locale) }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = DayOfWeek.MONDAY,
|
||||
)
|
||||
|
||||
// Anchor is a representative day inside the visible week; the actual week
|
||||
// start is derived against [weekStart], so changing the first-day preference
|
||||
// re-frames the same week instead of jumping.
|
||||
private val _anchor = MutableStateFlow(todayDate)
|
||||
|
||||
val weekStartDate: StateFlow<LocalDate> =
|
||||
combine(_anchor, weekStart) { anchor, ws -> anchor.startOfWeek(ws) }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = todayDate.startOfWeek(DayOfWeek.MONDAY),
|
||||
)
|
||||
|
||||
val state: StateFlow<WeekUiState> =
|
||||
combine(_anchor, weekStart) { anchor, ws -> anchor.startOfWeek(ws) }
|
||||
.distinctUntilChanged()
|
||||
.flatMapLatest { start ->
|
||||
val range = weekRange(start, zone)
|
||||
combine(
|
||||
repository.calendars(),
|
||||
repository.instances(range),
|
||||
) { calendars, instances ->
|
||||
buildState(start, calendars, instances)
|
||||
}
|
||||
}
|
||||
.catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = WeekUiState.Loading,
|
||||
)
|
||||
|
||||
fun goToPrev() {
|
||||
_anchor.value = _anchor.value.minus(7, DateTimeUnit.DAY)
|
||||
}
|
||||
|
||||
fun goToNext() {
|
||||
_anchor.value = _anchor.value.plus(7, DateTimeUnit.DAY)
|
||||
}
|
||||
|
||||
fun goToToday() {
|
||||
_anchor.value = todayDate
|
||||
}
|
||||
|
||||
private fun buildState(
|
||||
start: LocalDate,
|
||||
calendars: List<CalendarSource>,
|
||||
instances: List<EventInstance>,
|
||||
): WeekUiState {
|
||||
if (calendars.isEmpty()) {
|
||||
return WeekUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
}
|
||||
val days = (0 until 7).map { start.plus(it, DateTimeUnit.DAY) }
|
||||
val allDay = instances.filter { it.isAllDay }
|
||||
val timed = instances.filterNot { it.isAllDay }
|
||||
return WeekUiState.Success(
|
||||
weekStart = start,
|
||||
today = todayDate,
|
||||
days = days,
|
||||
allDaySpans = layoutAllDay(allDay, days, zone),
|
||||
timedByDay = days.associateWith { day -> layoutDay(timed, day, zone) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay out all-day events as connected horizontal spans across the visible week.
|
||||
* Each event becomes one [AllDaySpan] from its first to its last covered column;
|
||||
* overlapping spans are stacked on separate lanes (greedy first-fit by start).
|
||||
*/
|
||||
internal fun layoutAllDay(
|
||||
events: List<EventInstance>,
|
||||
days: List<LocalDate>,
|
||||
zone: TimeZone,
|
||||
): List<AllDaySpan> {
|
||||
data class Raw(val event: EventInstance, val startCol: Int, val endCol: Int)
|
||||
|
||||
val raw = events
|
||||
.mapNotNull { ev ->
|
||||
val covered = days.indices.filter { ev.coversDay(days[it], zone) }
|
||||
if (covered.isEmpty()) null else Raw(ev, covered.first(), covered.last())
|
||||
}
|
||||
.sortedWith(compareBy({ it.startCol }, { it.endCol }))
|
||||
|
||||
val laneEnd = ArrayList<Int>() // last occupied column per lane
|
||||
return raw.map { r ->
|
||||
var lane = laneEnd.indexOfFirst { it < r.startCol }
|
||||
if (lane == -1) {
|
||||
laneEnd.add(r.endCol)
|
||||
lane = laneEnd.size - 1
|
||||
} else {
|
||||
laneEnd[lane] = r.endCol
|
||||
}
|
||||
AllDaySpan(r.event, r.startCol, r.endCol, lane)
|
||||
}
|
||||
}
|
||||
|
||||
/** Beginning of the week (at [weekStart]) that contains this date. */
|
||||
internal fun LocalDate.startOfWeek(weekStart: DayOfWeek): LocalDate {
|
||||
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
|
||||
val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
||||
return minus(offset, DateTimeUnit.DAY)
|
||||
}
|
||||
|
||||
/** Half-open instant range covering the seven days starting at [start]. */
|
||||
internal fun weekRange(start: LocalDate, zone: TimeZone): ClosedRange<Instant> {
|
||||
val from = start.atStartOfDayIn(zone)
|
||||
val to = start.plus(6, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone)
|
||||
return from..to
|
||||
}
|
||||
|
||||
/** True if this event overlaps the calendar [day] in [zone] (any portion). */
|
||||
internal fun EventInstance.coversDay(day: LocalDate, zone: TimeZone): Boolean {
|
||||
val dayStart = day.atStartOfDayIn(zone)
|
||||
val dayEnd = day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
|
||||
return start < dayEnd && end > dayStart
|
||||
}
|
||||
|
||||
/**
|
||||
* Clip [events] to a single [day] and assign lanes so overlapping events render
|
||||
* side-by-side. Lane count is computed per overlap-cluster (a maximal run of
|
||||
* chained-overlapping events), matching the common phone week-view behaviour.
|
||||
*
|
||||
* All-day events are ignored here — they live in the all-day strip.
|
||||
*/
|
||||
internal fun layoutDay(
|
||||
events: List<EventInstance>,
|
||||
day: LocalDate,
|
||||
zone: TimeZone,
|
||||
): List<TimedBlock> {
|
||||
val dayStart = day.atStartOfDayIn(zone)
|
||||
val dayEnd = day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
|
||||
|
||||
data class Raw(val event: EventInstance, val startMin: Int, val endMin: Int)
|
||||
|
||||
val raw = events.asSequence()
|
||||
.filterNot { it.isAllDay }
|
||||
.mapNotNull { ev ->
|
||||
if (ev.start == ev.end) {
|
||||
// Instant event: keep only if the point falls inside this day.
|
||||
if (ev.start < dayStart || ev.start >= dayEnd) return@mapNotNull null
|
||||
val m = (ev.start - dayStart).inWholeMinutes.toInt().coerceIn(0, MINUTES_PER_DAY)
|
||||
Raw(ev, m, m)
|
||||
} else {
|
||||
val s = maxOf(ev.start, dayStart)
|
||||
val e = minOf(ev.end, dayEnd)
|
||||
if (e <= s) return@mapNotNull null
|
||||
val startMin = (s - dayStart).inWholeMinutes.toInt().coerceIn(0, MINUTES_PER_DAY)
|
||||
val endMin = (e - dayStart).inWholeMinutes.toInt().coerceIn(startMin, MINUTES_PER_DAY)
|
||||
Raw(ev, startMin, endMin)
|
||||
}
|
||||
}
|
||||
.sortedWith(compareBy({ it.startMin }, { it.endMin }))
|
||||
.toList()
|
||||
|
||||
val result = ArrayList<TimedBlock>(raw.size)
|
||||
var i = 0
|
||||
while (i < raw.size) {
|
||||
// Grow a cluster of chained-overlapping events.
|
||||
var clusterEnd = raw[i].endMin
|
||||
var j = i + 1
|
||||
while (j < raw.size && raw[j].startMin < clusterEnd) {
|
||||
clusterEnd = maxOf(clusterEnd, raw[j].endMin)
|
||||
j++
|
||||
}
|
||||
val cluster = raw.subList(i, j)
|
||||
// Greedy first-fit column assignment (= max overlap depth in the cluster).
|
||||
val laneEnd = ArrayList<Int>()
|
||||
val lanes = IntArray(cluster.size)
|
||||
cluster.forEachIndexed { k, r ->
|
||||
var placed = laneEnd.indexOfFirst { it <= r.startMin }
|
||||
if (placed == -1) {
|
||||
laneEnd.add(r.endMin)
|
||||
placed = laneEnd.size - 1
|
||||
} else {
|
||||
laneEnd[placed] = r.endMin
|
||||
}
|
||||
lanes[k] = placed
|
||||
}
|
||||
val laneCount = laneEnd.size
|
||||
cluster.forEachIndexed { k, r ->
|
||||
result.add(TimedBlock(r.event, r.startMin, r.endMin, lanes[k], laneCount))
|
||||
}
|
||||
i = j
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1,16 +1,95 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Calendula launcher icon foreground.
|
||||
|
||||
Converted from design/icon/calendula_mark.svg (232x232 viewport).
|
||||
Composition: rounded line-art calendar with a stylized "1" inside
|
||||
(referencing kalendae, the Latin word for the first day of the month
|
||||
that is the etymological root of both "calendar" and "calendula"),
|
||||
plus a small Calendula bloom as a badge in the bottom-right corner.
|
||||
|
||||
Strokes render in off-white (#FAF6F0) over the slate background
|
||||
drawable (drawable/ic_launcher_background.xml = @color/ic_launcher_background).
|
||||
The same vector is reused as the <monochrome> slot in the adaptive icon
|
||||
so Android 13+ themed-icon launchers can recolor it from wallpaper.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
android:viewportWidth="232"
|
||||
android:viewportHeight="232">
|
||||
<!--
|
||||
Stylized "1" centered in the 108x108 viewport.
|
||||
Reference: kalendae (the first day of the month) - etymological root
|
||||
of both "Calendar" and "Calendula".
|
||||
Color is off-white for high contrast on the slate background.
|
||||
Android adaptive icon spec: 108dp canvas.
|
||||
|
||||
Centering Logic:
|
||||
- The calendar body is a 142x142 square centered at (114, 108).
|
||||
- The viewport center is (116, 116).
|
||||
- We use pivot (114, 108) and translate by (2, 8) to align the
|
||||
calendar's geometric center perfectly with the canvas center,
|
||||
ignoring the visual weight of the bloom badge.
|
||||
|
||||
Scale:
|
||||
- Scaled by 0.50 to provide significant padding, preventing a
|
||||
"zoomed in" look on home screens and splash screens.
|
||||
-->
|
||||
<group
|
||||
android:pivotX="114"
|
||||
android:pivotY="108"
|
||||
android:scaleX="0.50"
|
||||
android:scaleY="0.50"
|
||||
android:translateX="2"
|
||||
android:translateY="8">
|
||||
<!-- Calendar body (rounded square with horizontal header divider) -->
|
||||
<path
|
||||
android:strokeColor="#FFFAF6F0"
|
||||
android:strokeWidth="12"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeMiterLimit="12"
|
||||
android:pathData="M43,69H185M185,115V63C185,48.64 173.359,37 159,37H69C54.64,37 43,48.64 43,63V153C43,167.359 54.64,179 69,179H124" />
|
||||
<!-- Numeral "1" inside the calendar body -->
|
||||
<path
|
||||
android:strokeColor="#FFFAF6F0"
|
||||
android:strokeWidth="12"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="M103,110L113.999,99V142.428" />
|
||||
<!-- Calendula bloom: 8 petals around a filled center -->
|
||||
<path
|
||||
android:strokeColor="#FFFAF6F0"
|
||||
android:strokeWidth="8"
|
||||
android:pathData="M163.072,136.714C163.072,142.886 168.214,153.429 170.786,157.929C173.357,153.429 178.5,142.886 178.5,136.714C178.5,130.543 173.357,129 170.786,129C168.214,129 163.072,130.543 163.072,136.714Z" />
|
||||
<path
|
||||
android:strokeColor="#FFFAF6F0"
|
||||
android:strokeWidth="8"
|
||||
android:pathData="M178.5,186.857C178.5,180.686 173.357,170.143 170.786,165.643C168.214,170.143 163.072,180.686 163.072,186.857C163.072,193.029 168.214,194.572 170.786,194.572C173.357,194.572 178.5,193.029 178.5,186.857Z" />
|
||||
<path
|
||||
android:strokeColor="#FFFAF6F0"
|
||||
android:strokeWidth="8"
|
||||
android:pathData="M195.857,154.072C189.686,154.072 179.143,159.214 174.643,161.786C179.143,164.357 189.686,169.5 195.857,169.5C202.029,169.5 203.572,164.357 203.572,161.786C203.572,159.214 202.029,154.072 195.857,154.072Z" />
|
||||
<path
|
||||
android:strokeColor="#FFFAF6F0"
|
||||
android:strokeWidth="8"
|
||||
android:pathData="M145.714,170.008C151.886,170.008 162.429,164.865 166.929,162.294C162.429,159.722 151.886,154.58 145.714,154.58C139.543,154.58 138,159.722 138,162.294C138,164.865 139.543,170.008 145.714,170.008Z" />
|
||||
<path
|
||||
android:strokeColor="#FFFAF6F0"
|
||||
android:strokeWidth="8"
|
||||
android:pathData="M194.768,174.858C190.404,170.494 179.312,166.676 174.312,165.312C175.676,170.312 179.494,181.404 183.858,185.768C188.222,190.132 192.949,187.586 194.768,185.768C196.586,183.949 199.132,179.222 194.768,174.858Z" />
|
||||
<path
|
||||
android:strokeColor="#FFFAF6F0"
|
||||
android:strokeWidth="8"
|
||||
android:pathData="M146.804,149.222C151.168,153.586 162.259,157.404 167.26,158.768C165.896,153.767 162.077,142.676 157.714,138.312C153.35,133.948 148.622,136.494 146.804,138.312C144.986,140.13 142.44,144.858 146.804,149.222Z" />
|
||||
<path
|
||||
android:strokeColor="#FFFAF6F0"
|
||||
android:strokeWidth="8"
|
||||
android:pathData="M183.858,138.312C179.494,142.676 175.676,153.767 174.312,158.768C179.312,157.404 190.404,153.586 194.768,149.222C199.132,144.858 196.586,140.13 194.768,138.312C192.949,136.494 188.222,133.948 183.858,138.312Z" />
|
||||
<path
|
||||
android:strokeColor="#FFFAF6F0"
|
||||
android:strokeWidth="8"
|
||||
android:pathData="M157.714,185.768C162.077,181.404 165.896,170.312 167.26,165.312C162.259,166.676 151.168,170.494 146.804,174.858C142.44,179.222 144.986,183.949 146.804,185.768C148.622,187.586 153.35,190.132 157.714,185.768Z" />
|
||||
<!-- Calendula center disc -->
|
||||
<path
|
||||
android:fillColor="#FFFAF6F0"
|
||||
android:pathData="M51.5,38 L51.5,38 C49.5,40 46.5,41.5 43,42.5 L43,49 C46.2,48.2 49,47 51.5,45.5 L51.5,72 L43.5,72 L43.5,76 L65.5,76 L65.5,72 L57.5,72 L57.5,38 Z" />
|
||||
android:pathData="M170.786,169C174.77,169 178,165.77 178,161.786C178,157.802 174.77,154.572 170.786,154.572C166.802,154.572 163.572,157.802 163.572,161.786C163.572,165.77 166.802,169 170.786,169Z" />
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -20,10 +20,86 @@
|
||||
<string name="permission_open_settings_button">System-Einstellungen öffnen</string>
|
||||
<string name="permission_retry_button">Erneut versuchen</string>
|
||||
|
||||
<!-- Debug-Screen (wegwerfbar — entfällt mit Plan 03) -->
|
||||
<string name="debug_banner">DEBUG — wird mit Plan 03 durch die Monatsansicht ersetzt</string>
|
||||
<string name="debug_calendars_header">Kalender</string>
|
||||
<string name="debug_events_header">Nächste 50 Termine</string>
|
||||
<string name="debug_no_calendars">Keine Kalender eingerichtet. Füge einen über DAVx5 oder die System-Einstellungen hinzu.</string>
|
||||
<string name="debug_no_events">Keine anstehenden Termine in den nächsten 30 Tagen.</string>
|
||||
<!-- Monatsansicht (S1) -->
|
||||
<string name="month_prev">Vorheriger Monat</string>
|
||||
<string name="month_next">Nächster Monat</string>
|
||||
<string name="month_today_action">Heute</string>
|
||||
<string name="month_more_actions">Weitere Aktionen</string>
|
||||
<string name="month_open_menu">Menü öffnen</string>
|
||||
<string name="month_action_settings">Einstellungen</string>
|
||||
<string name="month_a11y_today_prefix">Heute</string>
|
||||
|
||||
<!-- Wochenansicht (S2) -->
|
||||
<string name="week_today_action">Diese Woche</string>
|
||||
<string name="week_number_label">KW</string>
|
||||
|
||||
<!-- Tagesansicht (S3) -->
|
||||
<string name="day_today_action">Heute</string>
|
||||
|
||||
<!-- Event-Detail-Screen (S4) -->
|
||||
<string name="event_detail_back">Zurück</string>
|
||||
<string name="event_detail_edit">Bearbeiten</string>
|
||||
<string name="event_detail_delete">Löschen</string>
|
||||
<string name="event_detail_all_day">Ganztägig</string>
|
||||
<string name="event_detail_calendar">Kalender</string>
|
||||
<string name="event_detail_calendar_unknown">Unbekannter Kalender</string>
|
||||
<string name="event_detail_location">Ort</string>
|
||||
<string name="event_detail_description">Beschreibung</string>
|
||||
<string name="event_detail_attendees">Teilnehmer</string>
|
||||
<string name="event_detail_recurrence">Wiederholung</string>
|
||||
<string name="event_detail_recurring">Wiederkehrender Termin</string>
|
||||
<string name="recurrence_daily">Jeden Tag</string>
|
||||
<string name="recurrence_weekly">Jede Woche</string>
|
||||
<string name="recurrence_monthly">Jeden Monat</string>
|
||||
<string name="recurrence_yearly">Jedes Jahr</string>
|
||||
<string name="recurrence_every_n_days">Alle %1$d Tage</string>
|
||||
<string name="recurrence_every_n_weeks">Alle %1$d Wochen</string>
|
||||
<string name="recurrence_every_n_months">Alle %1$d Monate</string>
|
||||
<string name="recurrence_every_n_years">Alle %1$d Jahre</string>
|
||||
<string name="recurrence_on_days">%1$s am %2$s</string>
|
||||
<string name="recurrence_with_until">%1$s bis %2$s</string>
|
||||
<string name="recurrence_with_count">%1$s, %2$d Mal</string>
|
||||
<string name="event_detail_not_found">Dieser Termin existiert nicht mehr.</string>
|
||||
<string name="event_attendee_accepted">Zugesagt</string>
|
||||
<string name="event_attendee_declined">Abgesagt</string>
|
||||
<string name="event_attendee_tentative">Vorläufig</string>
|
||||
<string name="event_attendee_needs_action">Keine Antwort</string>
|
||||
<string name="event_attendee_unknown">—</string>
|
||||
|
||||
<!-- Geteilte Event-Strings -->
|
||||
<string name="event_untitled">(Ohne Titel)</string>
|
||||
|
||||
<!-- View-Switcher (M1) -->
|
||||
<string name="view_month">Monat</string>
|
||||
<string name="view_week">Woche</string>
|
||||
<string name="view_day">Tag</string>
|
||||
|
||||
<!-- Kalender-Filter (M3) -->
|
||||
<string name="filter_title">Kalender</string>
|
||||
|
||||
<!-- Einstellungen (M4) -->
|
||||
<string name="settings_title">Einstellungen</string>
|
||||
<string name="settings_back">Zurück</string>
|
||||
<string name="settings_section_appearance">Darstellung</string>
|
||||
<string name="settings_theme">Design</string>
|
||||
<string name="settings_theme_system">System</string>
|
||||
<string name="settings_theme_light">Hell</string>
|
||||
<string name="settings_theme_dark">Dunkel</string>
|
||||
<string name="settings_dynamic_color">Dynamische Farben</string>
|
||||
<string name="settings_dynamic_color_unavailable">Erfordert Android 12 oder neuer</string>
|
||||
<string name="settings_week_start">Wochenstart</string>
|
||||
<string name="settings_week_start_auto">Automatisch</string>
|
||||
<string name="settings_week_start_monday">Montag</string>
|
||||
<string name="settings_week_start_sunday">Sonntag</string>
|
||||
<string name="settings_section_language">Sprache</string>
|
||||
<string name="settings_language">App-Sprache</string>
|
||||
<string name="settings_language_auto">Systemstandard</string>
|
||||
<string name="settings_language_german">Deutsch</string>
|
||||
<string name="settings_language_english">English</string>
|
||||
<string name="settings_section_about">Über</string>
|
||||
<string name="settings_version">Version</string>
|
||||
<string name="settings_license">Lizenz</string>
|
||||
<string name="settings_license_value">MIT</string>
|
||||
<string name="settings_source">Quellcode</string>
|
||||
<string name="settings_source_open">Öffnen</string>
|
||||
</resources>
|
||||
|
||||
@@ -21,10 +21,87 @@
|
||||
<string name="permission_open_settings_button">Open system settings</string>
|
||||
<string name="permission_retry_button">Try again</string>
|
||||
|
||||
<!-- Debug screen (wegwerfbar — entfällt mit Plan 03) -->
|
||||
<string name="debug_banner">DEBUG — replaced by month view in Plan 03</string>
|
||||
<string name="debug_calendars_header">Calendars</string>
|
||||
<string name="debug_events_header">Next 50 events</string>
|
||||
<string name="debug_no_calendars">No calendars configured. Add one via DAVx5 or system settings.</string>
|
||||
<string name="debug_no_events">No upcoming events in the next 30 days.</string>
|
||||
<!-- Month view (S1) -->
|
||||
<string name="month_prev">Previous month</string>
|
||||
<string name="month_next">Next month</string>
|
||||
<string name="month_today_action">Today</string>
|
||||
<string name="month_more_actions">More actions</string>
|
||||
<string name="month_open_menu">Open menu</string>
|
||||
<string name="month_action_settings">Settings</string>
|
||||
<string name="month_a11y_today_prefix">Today</string>
|
||||
|
||||
<!-- Week view (S2) -->
|
||||
<string name="week_today_action">This week</string>
|
||||
<string name="week_number_label">Wk</string>
|
||||
|
||||
<!-- Day view (S3) -->
|
||||
<string name="day_today_action">Today</string>
|
||||
|
||||
<!-- Event detail screen (S4) -->
|
||||
<string name="event_detail_back">Back</string>
|
||||
<string name="event_detail_edit">Edit</string>
|
||||
<string name="event_detail_delete">Delete</string>
|
||||
<string name="event_detail_all_day">All day</string>
|
||||
<string name="event_detail_calendar">Calendar</string>
|
||||
<string name="event_detail_calendar_unknown">Unknown calendar</string>
|
||||
<string name="event_detail_location">Location</string>
|
||||
<string name="event_detail_description">Description</string>
|
||||
<string name="event_detail_attendees">Attendees</string>
|
||||
<string name="event_detail_recurrence">Recurrence</string>
|
||||
<string name="event_detail_recurring">Repeating event</string>
|
||||
<string name="recurrence_daily">Every day</string>
|
||||
<string name="recurrence_weekly">Every week</string>
|
||||
<string name="recurrence_monthly">Every month</string>
|
||||
<string name="recurrence_yearly">Every year</string>
|
||||
<string name="recurrence_every_n_days">Every %1$d days</string>
|
||||
<string name="recurrence_every_n_weeks">Every %1$d weeks</string>
|
||||
<string name="recurrence_every_n_months">Every %1$d months</string>
|
||||
<string name="recurrence_every_n_years">Every %1$d years</string>
|
||||
<string name="recurrence_on_days">%1$s on %2$s</string>
|
||||
<string name="recurrence_with_until">%1$s until %2$s</string>
|
||||
<string name="recurrence_with_count">%1$s, %2$d times</string>
|
||||
<string name="event_detail_not_found">This event no longer exists.</string>
|
||||
<string name="event_attendee_accepted">Accepted</string>
|
||||
<string name="event_attendee_declined">Declined</string>
|
||||
<string name="event_attendee_tentative">Tentative</string>
|
||||
<string name="event_attendee_needs_action">No response</string>
|
||||
<string name="event_attendee_unknown">—</string>
|
||||
|
||||
<!-- Shared event strings -->
|
||||
<string name="event_untitled">(No title)</string>
|
||||
|
||||
<!-- View switcher (M1) -->
|
||||
<string name="view_month">Month</string>
|
||||
<string name="view_week">Week</string>
|
||||
<string name="view_day">Day</string>
|
||||
|
||||
<!-- Calendar filter (M3) -->
|
||||
<string name="filter_title">Calendars</string>
|
||||
|
||||
<!-- Settings (M4) -->
|
||||
<string name="settings_title">Settings</string>
|
||||
<string name="settings_back">Back</string>
|
||||
<string name="settings_section_appearance">Appearance</string>
|
||||
<string name="settings_theme">Theme</string>
|
||||
<string name="settings_theme_system">System</string>
|
||||
<string name="settings_theme_light">Light</string>
|
||||
<string name="settings_theme_dark">Dark</string>
|
||||
<string name="settings_dynamic_color">Dynamic colour</string>
|
||||
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string>
|
||||
<string name="settings_week_start">Week starts on</string>
|
||||
<string name="settings_week_start_auto">Automatic</string>
|
||||
<string name="settings_week_start_monday">Monday</string>
|
||||
<string name="settings_week_start_sunday">Sunday</string>
|
||||
<string name="settings_section_language">Language</string>
|
||||
<string name="settings_language">App language</string>
|
||||
<string name="settings_language_auto">System default</string>
|
||||
<string name="settings_language_german">Deutsch</string>
|
||||
<string name="settings_language_english">English</string>
|
||||
<string name="settings_section_about">About</string>
|
||||
<string name="settings_version">Version</string>
|
||||
<string name="settings_license">License</string>
|
||||
<string name="settings_license_value">MIT</string>
|
||||
<string name="settings_source">Source code</string>
|
||||
<string name="settings_source_open">Open</string>
|
||||
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -10,15 +14,29 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.time.Instant
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class CalendarRepositoryImplTest {
|
||||
|
||||
private fun newPrefs(tempDir: Path): CalendarPrefs =
|
||||
CalendarPrefs(newDataStore(tempDir))
|
||||
|
||||
private fun newDataStore(tempDir: Path): DataStore<Preferences> =
|
||||
PreferenceDataStoreFactory.create(
|
||||
produceFile = { tempDir.resolve("repo_test_prefs.preferences_pb").toFile() },
|
||||
)
|
||||
|
||||
private fun makeCal(id: Long, name: String = "Cal $id"): CalendarSource =
|
||||
CalendarSource(id, name, "x@y", "LOCAL", 0xFF112233.toInt(), true)
|
||||
|
||||
private fun makeEvent(id: Long, title: String = "E $id"): EventInstance = EventInstance(
|
||||
instanceId = id, eventId = id, calendarId = 1L,
|
||||
private fun makeEvent(
|
||||
id: Long,
|
||||
title: String = "E $id",
|
||||
calendarId: Long = 1L,
|
||||
): EventInstance = EventInstance(
|
||||
instanceId = id, eventId = id, calendarId = calendarId,
|
||||
title = title,
|
||||
start = Instant.fromEpochMilliseconds(1_000_000_000L),
|
||||
end = Instant.fromEpochMilliseconds(1_000_003_600L),
|
||||
@@ -26,11 +44,11 @@ class CalendarRepositoryImplTest {
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `calendars emits initial query result on subscribe`() = runTest {
|
||||
fun `calendars emits initial query result on subscribe`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(makeCal(1L), makeCal(2L))
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, UnconfinedTestDispatcher(testScheduler))
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||
|
||||
repo.calendars().test {
|
||||
val first = awaitItem()
|
||||
@@ -40,11 +58,11 @@ class CalendarRepositoryImplTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calendars re-emits after change listener tick`() = runTest {
|
||||
fun `calendars re-emits after change listener tick`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(makeCal(1L))
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, UnconfinedTestDispatcher(testScheduler))
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||
|
||||
repo.calendars().test {
|
||||
assertThat(awaitItem().map { it.id }).containsExactly(1L)
|
||||
@@ -58,7 +76,7 @@ class CalendarRepositoryImplTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `instances forwards epoch-millis bounds to data source`() = runTest {
|
||||
fun `instances forwards epoch-millis bounds to data source`(@TempDir tempDir: Path) = runTest {
|
||||
var observedBegin: Long? = null
|
||||
var observedEnd: Long? = null
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
@@ -68,7 +86,7 @@ class CalendarRepositoryImplTest {
|
||||
listOf(makeEvent(10L))
|
||||
}
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, UnconfinedTestDispatcher(testScheduler))
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||
|
||||
val range = Instant.fromEpochMilliseconds(1_000L)..Instant.fromEpochMilliseconds(2_000L)
|
||||
repo.instances(range).test {
|
||||
@@ -80,11 +98,11 @@ class CalendarRepositoryImplTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `instances passes-through whatever the data source returns`() = runTest {
|
||||
fun `instances passes-through whatever the data source returns`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
instancesResult = {_, _ -> listOf(makeEvent(10L, "Good")) }
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, UnconfinedTestDispatcher(testScheduler))
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||
|
||||
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
|
||||
repo.instances(range).test {
|
||||
@@ -95,11 +113,56 @@ class CalendarRepositoryImplTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `eventDetail throws NoSuchEventException when data source returns null`() = runTest {
|
||||
fun `instances drops events whose calendar the user hid`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = newPrefs(tempDir)
|
||||
prefs.setHiddenCalendarIds(setOf(2L))
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
instancesResult = { _, _ ->
|
||||
listOf(
|
||||
makeEvent(10L, "Visible", calendarId = 1L),
|
||||
makeEvent(11L, "Hidden", calendarId = 2L),
|
||||
)
|
||||
}
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, prefs, UnconfinedTestDispatcher(testScheduler))
|
||||
|
||||
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
|
||||
repo.instances(range).test {
|
||||
assertThat(awaitItem().map { it.title }).containsExactly("Visible")
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `instances re-emits when the hidden set changes`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = newPrefs(tempDir)
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
instancesResult = { _, _ ->
|
||||
listOf(
|
||||
makeEvent(10L, "A", calendarId = 1L),
|
||||
makeEvent(11L, "B", calendarId = 2L),
|
||||
)
|
||||
}
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, prefs, UnconfinedTestDispatcher(testScheduler))
|
||||
|
||||
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
|
||||
repo.instances(range).test {
|
||||
assertThat(awaitItem().map { it.title }).containsExactly("A", "B").inOrder()
|
||||
|
||||
prefs.setHiddenCalendarIds(setOf(2L))
|
||||
|
||||
assertThat(awaitItem().map { it.title }).containsExactly("A")
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `eventDetail throws NoSuchEventException when data source returns null`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
eventDetailResult = { null }
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, Dispatchers.Unconfined)
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||
|
||||
try {
|
||||
repo.eventDetail(eventId = 999L)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package de.jeanlucmakiola.calendula.data.prefs
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
import java.util.Locale
|
||||
|
||||
class SettingsPrefsTest {
|
||||
|
||||
private fun newDataStore(tempDir: Path): DataStore<Preferences> =
|
||||
PreferenceDataStoreFactory.create(
|
||||
produceFile = { tempDir.resolve("settings_test.preferences_pb").toFile() },
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `defaults are system theme, dynamic colour on, auto week start`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.SYSTEM)
|
||||
assertThat(prefs.dynamicColor.first()).isTrue()
|
||||
assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.AUTO)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `theme mode round-trips`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
prefs.setThemeMode(ThemeMode.DARK)
|
||||
assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.DARK)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dynamic colour round-trips`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
prefs.setDynamicColor(false)
|
||||
assertThat(prefs.dynamicColor.first()).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `week start round-trips`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
prefs.setWeekStart(WeekStartPref.SUNDAY)
|
||||
assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.SUNDAY)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `garbage stored enum falls back to default`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newDataStore(tempDir)
|
||||
val prefs = SettingsPrefs(store)
|
||||
store.updateData { p ->
|
||||
val m = p.toMutablePreferences()
|
||||
m[SettingsPrefs.THEME_MODE_KEY] = "PLAID"
|
||||
m
|
||||
}
|
||||
assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.SYSTEM)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `explicit week-start prefs resolve regardless of locale`() {
|
||||
assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY)
|
||||
assertThat(WeekStartPref.SUNDAY.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.SUNDAY)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `auto week start follows the locale convention`() {
|
||||
assertThat(WeekStartPref.AUTO.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.MONDAY)
|
||||
assertThat(WeekStartPref.AUTO.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.SUNDAY)
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.debug
|
||||
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlin.time.Instant
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class DebugViewModelTest {
|
||||
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
private class FakeRepo(
|
||||
val calendarsFlow: MutableStateFlow<List<CalendarSource>> = MutableStateFlow(emptyList()),
|
||||
val instancesFlow: MutableStateFlow<List<EventInstance>> = MutableStateFlow(emptyList()),
|
||||
) : CalendarRepository {
|
||||
override fun calendars(): Flow<List<CalendarSource>> = calendarsFlow
|
||||
override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> = instancesFlow
|
||||
override suspend fun eventDetail(eventId: Long): EventDetail =
|
||||
throw NoSuchEventException(eventId)
|
||||
}
|
||||
|
||||
private fun makeCal(id: Long, name: String = "C $id"): CalendarSource =
|
||||
CalendarSource(id, name, "x@y", "LOCAL", 0xFF112233.toInt(), true)
|
||||
|
||||
private fun makeEvent(id: Long, title: String = "E $id") = EventInstance(
|
||||
instanceId = id, eventId = id, calendarId = 1L,
|
||||
title = title,
|
||||
start = Instant.fromEpochMilliseconds(0L),
|
||||
end = Instant.fromEpochMilliseconds(60_000L),
|
||||
isAllDay = false, color = 0xFF000000.toInt(), location = null,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `initial state value is Loading before any subscriber`() {
|
||||
val repo = FakeRepo()
|
||||
val vm = DebugViewModel(repo, testDispatcher)
|
||||
assertThat(vm.state.value).isEqualTo(DebugUiState.Loading)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Success contains calendars and capped events after subscription`() = runTest {
|
||||
val repo = FakeRepo(
|
||||
calendarsFlow = MutableStateFlow(listOf(makeCal(1L))),
|
||||
instancesFlow = MutableStateFlow(listOf(makeEvent(10L, "X"))),
|
||||
)
|
||||
val vm = DebugViewModel(repo, testDispatcher)
|
||||
vm.state.test {
|
||||
val success = awaitItem() as DebugUiState.Success
|
||||
assertThat(success.calendars.map { it.id }).containsExactly(1L)
|
||||
assertThat(success.nextEvents.map { it.title }).containsExactly("X")
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `instances are capped at 50`() = runTest {
|
||||
val repo = FakeRepo(
|
||||
calendarsFlow = MutableStateFlow(listOf(makeCal(1L))),
|
||||
instancesFlow = MutableStateFlow((1L..100L).map { makeEvent(it, "E$it") }),
|
||||
)
|
||||
val vm = DebugViewModel(repo, testDispatcher)
|
||||
vm.state.test {
|
||||
val success = awaitItem() as DebugUiState.Success
|
||||
assertThat(success.nextEvents).hasSize(50)
|
||||
assertThat(success.nextEvents.first().instanceId).isEqualTo(1L)
|
||||
assertThat(success.nextEvents.last().instanceId).isEqualTo(50L)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state updates when repository emits new data`() = runTest {
|
||||
val repo = FakeRepo()
|
||||
val vm = DebugViewModel(repo, testDispatcher)
|
||||
vm.state.test {
|
||||
// Empty initial: combine fires once because both StateFlows have initial empty value
|
||||
val empty = awaitItem() as DebugUiState.Success
|
||||
assertThat(empty.calendars).isEmpty()
|
||||
assertThat(empty.nextEvents).isEmpty()
|
||||
|
||||
repo.calendarsFlow.value = listOf(makeCal(1L), makeCal(2L))
|
||||
val updated = awaitItem() as DebugUiState.Success
|
||||
assertThat(updated.calendars.map { it.id }).containsExactly(1L, 2L).inOrder()
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package de.jeanlucmakiola.calendula.ui.filter
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class FilterGroupingTest {
|
||||
|
||||
private fun cal(
|
||||
id: Long,
|
||||
name: String,
|
||||
account: String,
|
||||
type: String = "com.example",
|
||||
) = CalendarSource(
|
||||
id = id,
|
||||
displayName = name,
|
||||
accountName = account,
|
||||
accountType = type,
|
||||
color = 0xFF336699.toInt(),
|
||||
isVisibleInSystem = true,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `groups calendars under their account, preserving order`() {
|
||||
val calendars = listOf(
|
||||
cal(1, "Personal", "alice@dav"),
|
||||
cal(2, "Work", "alice@dav"),
|
||||
cal(3, "Shared", "team@dav"),
|
||||
)
|
||||
|
||||
val groups = groupByAccount(calendars, hidden = emptySet())
|
||||
|
||||
assertThat(groups.map { it.account }).containsExactly("alice@dav", "team@dav").inOrder()
|
||||
assertThat(groups[0].calendars.map { it.displayName })
|
||||
.containsExactly("Personal", "Work").inOrder()
|
||||
assertThat(groups[1].calendars.map { it.id }).containsExactly(3L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hidden ids mark calendars not visible`() {
|
||||
val calendars = listOf(
|
||||
cal(1, "Personal", "alice@dav"),
|
||||
cal(2, "Work", "alice@dav"),
|
||||
)
|
||||
|
||||
val groups = groupByAccount(calendars, hidden = setOf(2L))
|
||||
val rows = groups.single().calendars.associateBy { it.id }
|
||||
|
||||
assertThat(rows.getValue(1L).visible).isTrue()
|
||||
assertThat(rows.getValue(2L).visible).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank account name falls back to type`() {
|
||||
val groups = groupByAccount(
|
||||
listOf(cal(1, "Birthdays", account = "", type = "LOCAL")),
|
||||
hidden = emptySet(),
|
||||
)
|
||||
assertThat(groups.single().account).isEqualTo("LOCAL")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package de.jeanlucmakiola.calendula.ui.week
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlin.time.Instant
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class WeekLayoutTest {
|
||||
|
||||
private val zone = TimeZone.UTC
|
||||
|
||||
// 2026-06-10 is a Wednesday; its Monday-anchored week starts 2026-06-08.
|
||||
private val wed = LocalDate(2026, 6, 10)
|
||||
private val mon = LocalDate(2026, 6, 8)
|
||||
private val weekDays = (0..6).map { mon.plusDays(it) }
|
||||
|
||||
private fun at(date: LocalDate, h: Int, m: Int = 0): Instant =
|
||||
date.atTime(h, m).toInstant(zone)
|
||||
|
||||
private fun event(
|
||||
start: Instant,
|
||||
end: Instant,
|
||||
allDay: Boolean = false,
|
||||
id: Long = 1L,
|
||||
title: String = "E",
|
||||
) = EventInstance(
|
||||
instanceId = id,
|
||||
eventId = id,
|
||||
calendarId = 1L,
|
||||
title = title,
|
||||
start = start,
|
||||
end = end,
|
||||
isAllDay = allDay,
|
||||
color = 0xFF112233.toInt(),
|
||||
location = null,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `startOfWeek snaps to monday`() {
|
||||
assertThat(wed.startOfWeek(DayOfWeek.MONDAY)).isEqualTo(mon)
|
||||
assertThat(mon.startOfWeek(DayOfWeek.MONDAY)).isEqualTo(mon)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `weekRange spans seven days`() {
|
||||
val range = weekRange(mon, zone)
|
||||
assertThat(range.start).isEqualTo(at(mon, 0, 0))
|
||||
// endInclusive is the last second of day 7 (Sunday 2026-06-14)
|
||||
assertThat(range.endInclusive).isEqualTo(LocalDate(2026, 6, 14).atTime(23, 59, 59).toInstant(zone))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `coversDay is true for any overlap and false otherwise`() {
|
||||
val ev = event(at(wed, 9), at(wed, 10))
|
||||
assertThat(ev.coversDay(wed, zone)).isTrue()
|
||||
assertThat(ev.coversDay(mon, zone)).isFalse()
|
||||
|
||||
val multiDay = event(at(mon, 22), at(wed, 2), allDay = true)
|
||||
assertThat(multiDay.coversDay(mon, zone)).isTrue()
|
||||
assertThat(multiDay.coversDay(LocalDate(2026, 6, 9), zone)).isTrue()
|
||||
assertThat(multiDay.coversDay(wed, zone)).isTrue()
|
||||
assertThat(multiDay.coversDay(LocalDate(2026, 6, 12), zone)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single timed event gets one lane`() {
|
||||
val blocks = layoutDay(listOf(event(at(wed, 9), at(wed, 10, 30))), wed, zone)
|
||||
assertThat(blocks).hasSize(1)
|
||||
val b = blocks.single()
|
||||
assertThat(b.startMin).isEqualTo(9 * 60)
|
||||
assertThat(b.endMin).isEqualTo(10 * 60 + 30)
|
||||
assertThat(b.lane).isEqualTo(0)
|
||||
assertThat(b.laneCount).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `overlapping events resolve to side-by-side lanes`() {
|
||||
val a = event(at(wed, 9), at(wed, 11), id = 1L)
|
||||
val b = event(at(wed, 10), at(wed, 12), id = 2L)
|
||||
val blocks = layoutDay(listOf(a, b), wed, zone).sortedBy { it.lane }
|
||||
assertThat(blocks.map { it.lane }).containsExactly(0, 1)
|
||||
assertThat(blocks.all { it.laneCount == 2 }).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `back-to-back events reuse one lane`() {
|
||||
val a = event(at(wed, 9), at(wed, 10), id = 1L)
|
||||
val b = event(at(wed, 10), at(wed, 11), id = 2L)
|
||||
val blocks = layoutDay(listOf(a, b), wed, zone)
|
||||
assertThat(blocks).hasSize(2)
|
||||
assertThat(blocks.all { it.lane == 0 && it.laneCount == 1 }).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `event spanning midnight is clipped to the day`() {
|
||||
// Starts the previous evening, ends 02:00 on wed.
|
||||
val ev = event(at(mon.plusDays(1), 22), at(wed, 2))
|
||||
val blocks = layoutDay(listOf(ev), wed, zone)
|
||||
assertThat(blocks).hasSize(1)
|
||||
assertThat(blocks.single().startMin).isEqualTo(0)
|
||||
assertThat(blocks.single().endMin).isEqualTo(2 * 60)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `instant event is kept with zero-length`() {
|
||||
val ev = event(at(wed, 12), at(wed, 12))
|
||||
val blocks = layoutDay(listOf(ev), wed, zone)
|
||||
assertThat(blocks).hasSize(1)
|
||||
assertThat(blocks.single().startMin).isEqualTo(12 * 60)
|
||||
assertThat(blocks.single().endMin).isEqualTo(12 * 60)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all-day events are excluded from the timed layout`() {
|
||||
val ev = event(at(wed, 0), at(wed.plusDays(1), 0), allDay = true)
|
||||
assertThat(layoutDay(listOf(ev), wed, zone)).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `events on other days are dropped`() {
|
||||
val ev = event(at(mon, 9), at(mon, 10))
|
||||
assertThat(layoutDay(listOf(ev), wed, zone)).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single-day all-day event is a one-column span`() {
|
||||
// Wed only: start Wed 00:00, end Thu 00:00.
|
||||
val ev = event(at(weekDays[2], 0), at(weekDays[3], 0), allDay = true)
|
||||
val spans = layoutAllDay(listOf(ev), weekDays, zone)
|
||||
assertThat(spans).hasSize(1)
|
||||
val s = spans.single()
|
||||
assertThat(s.startCol).isEqualTo(2)
|
||||
assertThat(s.endCol).isEqualTo(2)
|
||||
assertThat(s.lane).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `multi-day all-day event becomes one span across columns`() {
|
||||
// Tue..Thu: end Fri 00:00 is exclusive, so Fri is not covered.
|
||||
val ev = event(at(weekDays[1], 0), at(weekDays[4], 0), allDay = true)
|
||||
val s = layoutAllDay(listOf(ev), weekDays, zone).single()
|
||||
assertThat(s.startCol).isEqualTo(1)
|
||||
assertThat(s.endCol).isEqualTo(3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `span reaching outside the week is clamped to visible columns`() {
|
||||
// Starts two days before Monday, ends Wed 00:00 → covers Mon..Tue.
|
||||
val ev = event(at(mon.plusDays(-2), 0), at(weekDays[2], 0), allDay = true)
|
||||
val s = layoutAllDay(listOf(ev), weekDays, zone).single()
|
||||
assertThat(s.startCol).isEqualTo(0)
|
||||
assertThat(s.endCol).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `overlapping all-day spans get separate lanes`() {
|
||||
val a = event(at(weekDays[0], 0), at(weekDays[3], 0), allDay = true, id = 1L) // Mon..Wed
|
||||
val b = event(at(weekDays[1], 0), at(weekDays[4], 0), allDay = true, id = 2L) // Tue..Thu
|
||||
val spans = layoutAllDay(listOf(a, b), weekDays, zone)
|
||||
assertThat(spans.map { it.lane }.toSet()).isEqualTo(setOf(0, 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disjoint all-day spans reuse one lane`() {
|
||||
val a = event(at(weekDays[0], 0), at(weekDays[1], 0), allDay = true, id = 1L) // Mon
|
||||
val b = event(at(weekDays[3], 0), at(weekDays[4], 0), allDay = true, id = 2L) // Thu
|
||||
val spans = layoutAllDay(listOf(a, b), weekDays, zone)
|
||||
assertThat(spans.all { it.lane == 0 }).isTrue()
|
||||
}
|
||||
|
||||
private fun LocalDate.plusDays(n: Int): LocalDate = plus(n, DateTimeUnit.DAY)
|
||||
}
|
||||
34
design/icon/calendula_launcher.svg
Normal file
34
design/icon/calendula_launcher.svg
Normal file
@@ -0,0 +1,34 @@
|
||||
<svg width="232" height="232" viewBox="0 0 232 232" xmlns="http://www.w3.org/2000/svg">
|
||||
<!--
|
||||
Composed Calendula launcher icon (full-bleed), generated to exactly match the
|
||||
Android adaptive icon defined by:
|
||||
- drawable/ic_launcher_background.xml (solid @color/ic_launcher_background = #5C6B7A)
|
||||
- drawable/ic_launcher_foreground.xml (off-white #FAF6F0 mark from calendula_mark.svg)
|
||||
|
||||
The adaptive foreground group transform (scaleX/Y=0.50, pivot 114,108,
|
||||
translate 2,8) is reproduced here as the SVG transform "translate(59,62) scale(0.5)"
|
||||
because Android applies it as: p' = scale*p + pivot*(1-scale) + translate
|
||||
x' = 0.5*x + 114*0.5 + 2 = 0.5*x + 59
|
||||
y' = 0.5*y + 108*0.5 + 8 = 0.5*y + 62
|
||||
|
||||
This is the single source of truth for the F-Droid / store icon.png renders.
|
||||
-->
|
||||
<rect x="0" y="0" width="232" height="232" fill="#5C6B7A"/>
|
||||
<g transform="translate(59,62) scale(0.5)" fill="none" stroke="#FAF6F0">
|
||||
<!-- Calendar body (rounded square with horizontal header divider) -->
|
||||
<path d="M43 69H185M185 115V63C185 48.64 173.359 37 159 37H69C54.64 37 43 48.64 43 63V153C43 167.359 54.64 179 69 179H124" stroke-width="12" stroke-miterlimit="12" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<!-- Numeral "1" inside the calendar body -->
|
||||
<path d="M103 110L113.999 99V142.428" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<!-- Calendula bloom: 8 petals around a filled center -->
|
||||
<path d="M163.072 136.714C163.072 142.886 168.214 153.429 170.786 157.929C173.357 153.429 178.5 142.886 178.5 136.714C178.5 130.543 173.357 129 170.786 129C168.214 129 163.072 130.543 163.072 136.714Z" stroke-width="8"/>
|
||||
<path d="M178.5 186.857C178.5 180.686 173.357 170.143 170.786 165.643C168.214 170.143 163.072 180.686 163.072 186.857C163.072 193.029 168.214 194.572 170.786 194.572C173.357 194.572 178.5 193.029 178.5 186.857Z" stroke-width="8"/>
|
||||
<path d="M195.857 154.072C189.686 154.072 179.143 159.214 174.643 161.786C179.143 164.357 189.686 169.5 195.857 169.5C202.029 169.5 203.572 164.357 203.572 161.786C203.572 159.214 202.029 154.072 195.857 154.072Z" stroke-width="8"/>
|
||||
<path d="M145.714 170.008C151.886 170.008 162.429 164.865 166.929 162.294C162.429 159.722 151.886 154.58 145.714 154.58C139.543 154.58 138 159.722 138 162.294C138 164.865 139.543 170.008 145.714 170.008Z" stroke-width="8"/>
|
||||
<path d="M194.768 174.858C190.404 170.494 179.312 166.676 174.312 165.312C175.676 170.312 179.494 181.404 183.858 185.768C188.222 190.132 192.949 187.586 194.768 185.768C196.586 183.949 199.132 179.222 194.768 174.858Z" stroke-width="8"/>
|
||||
<path d="M146.804 149.222C151.168 153.586 162.259 157.404 167.26 158.768C165.896 153.767 162.077 142.676 157.714 138.312C153.35 133.948 148.622 136.494 146.804 138.312C144.986 140.13 142.44 144.858 146.804 149.222Z" stroke-width="8"/>
|
||||
<path d="M183.858 138.312C179.494 142.676 175.676 153.767 174.312 158.768C179.312 157.404 190.404 153.586 194.768 149.222C199.132 144.858 196.586 140.13 194.768 138.312C192.949 136.494 188.222 133.948 183.858 138.312Z" stroke-width="8"/>
|
||||
<path d="M157.714 185.768C162.077 181.404 165.896 170.312 167.26 165.312C162.259 166.676 151.168 170.494 146.804 174.858C142.44 179.222 144.986 183.949 146.804 185.768C148.622 187.586 153.35 190.132 157.714 185.768Z" stroke-width="8"/>
|
||||
<!-- Calendula center disc (filled, matches foreground <fillColor> slot) -->
|
||||
<path d="M170.786 169C174.77 169 178 165.77 178 161.786C178 157.802 174.77 154.572 170.786 154.572C166.802 154.572 163.572 157.802 163.572 161.786C163.572 165.77 166.802 169 170.786 169Z" fill="#FAF6F0" stroke="none"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
13
design/icon/calendula_mark.svg
Normal file
13
design/icon/calendula_mark.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg width="232" height="232" viewBox="0 0 232 232" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M43 69H185M185 115V63C185 48.64 173.359 37 159 37H69C54.64 37 43 48.64 43 63V153C43 167.359 54.64 179 69 179H124" stroke="black" stroke-width="12" stroke-miterlimit="12" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M103 110L113.999 99V142.428" stroke="black" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M163.072 136.714C163.072 142.886 168.214 153.429 170.786 157.929C173.357 153.429 178.5 142.886 178.5 136.714C178.5 130.543 173.357 129 170.786 129C168.214 129 163.072 130.543 163.072 136.714Z" stroke="black" stroke-width="8"/>
|
||||
<path d="M178.5 186.857C178.5 180.686 173.357 170.143 170.786 165.643C168.214 170.143 163.072 180.686 163.072 186.857C163.072 193.029 168.214 194.572 170.786 194.572C173.357 194.572 178.5 193.029 178.5 186.857Z" stroke="black" stroke-width="8"/>
|
||||
<path d="M195.857 154.072C189.686 154.072 179.143 159.214 174.643 161.786C179.143 164.357 189.686 169.5 195.857 169.5C202.029 169.5 203.572 164.357 203.572 161.786C203.572 159.214 202.029 154.072 195.857 154.072Z" stroke="black" stroke-width="8"/>
|
||||
<path d="M145.714 170.008C151.886 170.008 162.429 164.865 166.929 162.294C162.429 159.722 151.886 154.58 145.714 154.58C139.543 154.58 138 159.722 138 162.294C138 164.865 139.543 170.008 145.714 170.008Z" stroke="black" stroke-width="8"/>
|
||||
<path d="M194.768 174.858C190.404 170.494 179.312 166.676 174.312 165.312C175.676 170.312 179.494 181.404 183.858 185.768C188.222 190.132 192.949 187.586 194.768 185.768C196.586 183.949 199.132 179.222 194.768 174.858Z" stroke="black" stroke-width="8"/>
|
||||
<path d="M146.804 149.222C151.168 153.586 162.259 157.404 167.26 158.768C165.896 153.767 162.077 142.676 157.714 138.312C153.35 133.948 148.622 136.494 146.804 138.312C144.986 140.13 142.44 144.858 146.804 149.222Z" stroke="black" stroke-width="8"/>
|
||||
<path d="M183.858 138.312C179.494 142.676 175.676 153.767 174.312 158.768C179.312 157.404 190.404 153.586 194.768 149.222C199.132 144.858 196.586 140.13 194.768 138.312C192.949 136.494 188.222 133.948 183.858 138.312Z" stroke="black" stroke-width="8"/>
|
||||
<path d="M157.714 185.768C162.077 181.404 165.896 170.312 167.26 165.312C162.259 166.676 151.168 170.494 146.804 174.858C142.44 179.222 144.986 183.949 146.804 185.768C148.622 187.586 153.35 190.132 157.714 185.768Z" stroke="black" stroke-width="8"/>
|
||||
<path d="M170.786 169C174.77 169 178 165.77 178 161.786C178 157.802 174.77 154.572 170.786 154.572C166.802 154.572 163.572 157.802 163.572 161.786C163.572 165.77 166.802 169 170.786 169Z" fill="black" stroke="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 16 KiB |
13
gradle/gradle-daemon-jvm.properties
Normal file
13
gradle/gradle-daemon-jvm.properties
Normal file
@@ -0,0 +1,13 @@
|
||||
#This file is generated by updateDaemonJvm
|
||||
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
|
||||
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
|
||||
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
|
||||
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
|
||||
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7083b89563e7ce20943037b8cd2b8cc2/redirect
|
||||
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/060bbb778a1f55ea705fdebd2ccfeab9/redirect
|
||||
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
|
||||
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
|
||||
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d09679dc60fe5aa05ef7d03efdefac20/redirect
|
||||
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ed4e3bf2f5e7c5d9aabc4cbd8acd555e/redirect
|
||||
toolchainVendor=JETBRAINS
|
||||
toolchainVersion=21
|
||||
@@ -1,9 +1,10 @@
|
||||
[versions]
|
||||
agp = "9.1.1"
|
||||
agp = "9.2.1"
|
||||
kotlin = "2.3.21"
|
||||
ksp = "2.3.9"
|
||||
hilt = "2.59.2"
|
||||
coreKtx = "1.19.0"
|
||||
appcompat = "1.7.1"
|
||||
lifecycleRuntime = "2.10.0"
|
||||
activityCompose = "1.13.0"
|
||||
composeBom = "2026.05.01"
|
||||
@@ -27,6 +28,7 @@ androidxTestRules = "1.7.0"
|
||||
[libraries]
|
||||
# AndroidX core
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntime" }
|
||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||
|
||||
@@ -41,6 +43,8 @@ androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-man
|
||||
|
||||
# Material 3 (Expressive lives in this artifact for 1.5+)
|
||||
androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
|
||||
androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" }
|
||||
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||
|
||||
# Hilt
|
||||
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
|
||||
|
||||
@@ -11,6 +11,9 @@ pluginManagement {
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
plugins {
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
|
||||
Reference in New Issue
Block a user