Compare commits

..
58 changed files with 1146 additions and 376 deletions
-1
View File
@@ -177,7 +177,6 @@ dependencies {
implementation("de.jeanlucmakiola.floret:core-crash")
implementation("de.jeanlucmakiola.floret:core-di")
implementation("de.jeanlucmakiola.floret:core-reminders")
implementation("de.jeanlucmakiola.floret:core-android")
implementation("de.jeanlucmakiola.floret:identity")
implementation("de.jeanlucmakiola.floret:components")
implementation(libs.kotlinx.coroutines.core)
@@ -5,10 +5,12 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
@@ -26,7 +28,6 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import de.jeanlucmakiola.floret.android.hasPermission
import kotlinx.coroutines.flow.first
import java.util.concurrent.TimeUnit
import kotlin.time.Clock
@@ -132,7 +133,8 @@ class BackupWorker(
private fun notifyFailure(context: Context) {
val canPost = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
context.hasPermission(Manifest.permission.POST_NOTIFICATIONS)
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
if (!canPost) return
val manager = NotificationManagerCompat.from(context)
manager.createNotificationChannel(
@@ -1,12 +1,12 @@
package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.floret.android.hasPermission
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
import android.Manifest
import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.content.pm.PackageManager
import android.database.ContentObserver
import android.database.Cursor
import android.net.Uri
@@ -14,6 +14,7 @@ import android.os.Handler
import android.os.Looper
import android.provider.CalendarContract
import android.util.Log
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.domain.Attendee
import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
@@ -325,7 +326,8 @@ class AndroidCalendarDataSource @Inject constructor(
private val registeredObservers = mutableSetOf<ContentObserver>()
private fun hasCalendarPermission(): Boolean =
context.hasPermission(Manifest.permission.READ_CALENDAR)
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) ==
PackageManager.PERMISSION_GRANTED
/**
* Attach any not-yet-registered observers to the provider, but only once the
@@ -461,7 +463,8 @@ class AndroidCalendarDataSource @Inject constructor(
}
override fun canWriteCalendars(): Boolean =
context.hasPermission(Manifest.permission.WRITE_CALENDAR)
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
PackageManager.PERMISSION_GRANTED
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
val name = displayName.trim()
@@ -4,6 +4,7 @@ import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.util.Log
import androidx.core.content.ContextCompat
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -11,7 +12,6 @@ import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.domain.calendarVisibilityPlan
import de.jeanlucmakiola.calendula.domain.hasSystemHiddenCalendars
import de.jeanlucmakiola.floret.android.hasPermission
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.first
@@ -47,7 +47,7 @@ class CalendarVisibilityReconciler @Inject constructor(
// Settled ahead of the permission gate, so an update installed
// before the first grant can't later look like an upgrade.
if (!isUpgradeInstall()) settleNoticeOnce(pending = false)
if (!context.hasPermission(Manifest.permission.READ_CALENDAR)) return@withContext
if (!hasPermission(Manifest.permission.READ_CALENDAR)) return@withContext
val pending = prefs.pendingDisabledCalendarIds.first()
val noticeSettled = prefs.visibilityNoticePending.first() != null
if (pending.isEmpty() && noticeSettled) return@withContext
@@ -56,7 +56,7 @@ class CalendarVisibilityReconciler @Inject constructor(
// Both decisions below are one-way, so leave them to the next run.
if (calendars.isEmpty()) return@withContext
settleNoticeOnce(hasSystemHiddenCalendars(calendars, pending))
if (pending.isEmpty() || !context.hasPermission(Manifest.permission.WRITE_CALENDAR)) {
if (pending.isEmpty() || !hasPermission(Manifest.permission.WRITE_CALENDAR)) {
return@withContext
}
val plan = calendarVisibilityPlan(calendars, pending)
@@ -97,6 +97,9 @@ class CalendarVisibilityReconciler @Inject constructor(
false
}
private fun hasPermission(permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
/** Lets non-injectable entry points (the Application) reach the reconciler. */
@EntryPoint
@InstallIn(SingletonComponent::class)
@@ -2,15 +2,16 @@ package de.jeanlucmakiola.calendula.data.contacts
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds.Event
import android.util.Log
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.domain.contacts.managedUid
import de.jeanlucmakiola.calendula.domain.contacts.parseContactEventDate
import de.jeanlucmakiola.floret.android.hasPermission
import javax.inject.Inject
import javax.inject.Singleton
@@ -30,7 +31,8 @@ interface ContactSpecialDatesDataSource {
/** Whether `READ_CONTACTS` is granted — the gate for every contacts read. */
fun Context.hasContactsPermission(): Boolean =
hasPermission(Manifest.permission.READ_CONTACTS)
ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) ==
PackageManager.PERMISSION_GRANTED
/** Map a `ContactsContract` event `TYPE` to our calendar bucket. */
internal fun specialDateTypeForRawEventType(type: Int): SpecialDateType = when (type) {
@@ -7,7 +7,6 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
import de.jeanlucmakiola.floret.reminders.applyReminderOverride
@@ -31,8 +30,10 @@ import de.jeanlucmakiola.calendula.widget.WidgetSize
import java.time.ZoneId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DayOfWeek
import java.time.temporal.WeekFields
import java.util.Locale
@@ -103,8 +104,9 @@ fun WeekStartPref.resolveFirstDay(locale: Locale): DayOfWeek = when (this) {
fun SettingsPrefs.firstDayOfWeek(scope: CoroutineScope): StateFlow<DayOfWeek> =
weekStart
.map { it.resolveFirstDay(Locale.getDefault()) }
.stateWhileSubscribed(
.stateIn(
scope = scope,
started = SharingStarted.WhileSubscribed(5_000L),
// Seed with the locale convention rather than a hardcoded weekday, so
// the first frame is already right for everyone still on Auto.
initialValue = WeekStartPref.Auto.resolveFirstDay(Locale.getDefault()),
@@ -6,8 +6,11 @@ import android.content.Intent
import androidx.core.net.toUri
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.floret.android.goAsync
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
@@ -41,17 +44,27 @@ class ReminderActionReceiver : BroadcastReceiver() {
// Cancel now so the notification doesn't linger until the alarm;
// the snooze delay read is the only async work.
notifier.cancel(alert)
goAsync {
val minutes = settingsPrefs.snoozeMinutes.first()
val triggerAt = System.currentTimeMillis() + minutes * 60_000L
scheduler.schedule(alert, triggerAt)
val pendingResult = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
val minutes = settingsPrefs.snoozeMinutes.first()
val triggerAt = System.currentTimeMillis() + minutes * 60_000L
scheduler.schedule(alert, triggerAt)
} finally {
pendingResult.finish()
}
}
}
ACTION_SHOW -> {
goAsync {
if (settingsPrefs.remindersEnabled.first() && notifier.canPost()) {
notifier.post(alert)
val pendingResult = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
if (settingsPrefs.remindersEnabled.first() && notifier.canPost()) {
notifier.post(alert)
}
} finally {
pendingResult.finish()
}
}
}
@@ -4,12 +4,20 @@ import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.content.getSystemService
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.floret.android.setWakeup
import javax.inject.Inject
import javax.inject.Singleton
/**
* True on API < 31 (no restriction), and on 31+ when the exact-alarm capability
* is held — auto-granted via `USE_EXACT_ALARM` on API 33+ (Calendula is a
* calendar app), user-revocable on 31–32.
*/
internal fun AlarmManager.canScheduleExactCompat(): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || canScheduleExactAlarms()
/**
* Holds the app's own wake-up for the next reminder (#75). Exactly one alarm
* exists at a time, for the earliest reminder ahead; every firing re-scans and
@@ -23,7 +31,16 @@ class ReminderAlarmScheduler @Inject constructor(
fun scheduleScan(triggerAtMillis: Long) {
val alarmManager = context.getSystemService<AlarmManager>() ?: return
alarmManager.setWakeup(triggerAtMillis, scanPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT))
val pendingIntent = scanPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT)
if (alarmManager.canScheduleExactCompat()) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
)
} else {
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
)
}
}
/** Drop the pending wake-up — reminders are off, or there is nothing to wait for. */
@@ -5,10 +5,12 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
@@ -17,7 +19,6 @@ import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.floret.android.hasPermission
import kotlinx.coroutines.flow.first
import kotlinx.datetime.isoDayNumber
import java.time.DayOfWeek
@@ -47,7 +48,8 @@ class ReminderNotifier @Inject constructor(
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
fun canPost(): Boolean {
val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
context.hasPermission(Manifest.permission.POST_NOTIFICATIONS)
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
}
@@ -2,7 +2,9 @@ package de.jeanlucmakiola.calendula.data.reminders
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.util.Log
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
import de.jeanlucmakiola.calendula.data.calendar.withoutProviderDefaults
@@ -12,7 +14,6 @@ import de.jeanlucmakiola.calendula.domain.reminders.planReminders
import de.jeanlucmakiola.calendula.domain.reminders.reminderQueryHorizon
import de.jeanlucmakiola.calendula.domain.reminders.reminderWatermark
import de.jeanlucmakiola.calendula.domain.reminders.scheduleReminders
import de.jeanlucmakiola.floret.android.hasPermission
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
@@ -112,7 +113,9 @@ class ReminderScanner @Inject constructor(
alarms.scheduleScan(schedule.nextAlarmMillis)
}
private fun hasReadCalendar(): Boolean = context.hasPermission(Manifest.permission.READ_CALENDAR)
private fun hasReadCalendar(): Boolean = ContextCompat.checkSelfPermission(
context, Manifest.permission.READ_CALENDAR,
) == PackageManager.PERMISSION_GRANTED
/**
* Re-scan when the provider changes, so a saved or deleted event re-arms the
@@ -4,7 +4,10 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.floret.android.goAsync
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
@@ -26,7 +29,14 @@ class ReminderScheduleReceiver : BroadcastReceiver() {
// exported and the broadcasts it takes are protected, so any other
// action did not come from where it claims to.
if (intent.action !in HANDLED_ACTIONS) return
goAsync { scanner.scan() }
val pendingResult = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
scanner.scan()
} finally {
pendingResult.finish()
}
}
}
companion object {
@@ -3,9 +3,9 @@ package de.jeanlucmakiola.calendula.data.reminders
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.os.Build
import androidx.core.content.getSystemService
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.floret.android.setWakeup
import javax.inject.Inject
import javax.inject.Singleton
@@ -31,6 +31,24 @@ class ReminderSnoozeScheduler @Inject constructor(
ReminderActionReceiver.intent(context, ReminderActionReceiver.ACTION_SHOW, alert),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
alarmManager.setWakeup(triggerAtMillis, pendingIntent)
if (canScheduleExact(alarmManager)) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
)
} else {
// Exact alarms revoked (API 31–32); an inexact wake is the best
// available without nagging for SCHEDULE_EXACT_ALARM.
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
)
}
}
/**
* True on API < 31 (no restriction), and on 31+ when the exact-alarm
* capability is held — auto-granted via `USE_EXACT_ALARM` on API 33+
* (Calendula is a calendar app), user-revocable on 31–32.
*/
private fun canScheduleExact(alarmManager: AlarmManager): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarmManager.canScheduleExactAlarms()
}
@@ -1,13 +1,18 @@
package de.jeanlucmakiola.calendula.data.time
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.floret.android.broadcastTicks
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.transformLatest
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
@@ -29,8 +34,21 @@ class AndroidTodaySource @Inject constructor(
@ApplicationContext private val context: Context,
) : TodaySource {
override val today: Flow<LocalDate> = context
.broadcastTicks(Intent.ACTION_DATE_CHANGED, Intent.ACTION_TIME_CHANGED, Intent.ACTION_TIMEZONE_CHANGED)
override val today: Flow<LocalDate> = callbackFlow {
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
trySend(Unit)
}
}
val filter = IntentFilter().apply {
addAction(Intent.ACTION_DATE_CHANGED)
addAction(Intent.ACTION_TIME_CHANGED)
addAction(Intent.ACTION_TIMEZONE_CHANGED)
}
ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
awaitClose { context.unregisterReceiver(receiver) }
}
.onStart { emit(Unit) }
.transformLatest {
while (true) {
emit(currentDate())
@@ -1,12 +1,17 @@
package de.jeanlucmakiola.calendula.data.time
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.floret.android.broadcastTicks
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.datetime.TimeZone
import javax.inject.Inject
@@ -28,7 +33,17 @@ class AndroidZoneSource @Inject constructor(
@ApplicationContext private val context: Context,
) : ZoneSource {
override val zone: Flow<TimeZone> = context.broadcastTicks(Intent.ACTION_TIMEZONE_CHANGED)
override val zone: Flow<TimeZone> = callbackFlow {
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
trySend(Unit)
}
}
val filter = IntentFilter(Intent.ACTION_TIMEZONE_CHANGED)
ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
awaitClose { context.unregisterReceiver(receiver) }
}
.onStart { emit(Unit) }
.map { current() }
.distinctUntilChanged()
@@ -8,10 +8,11 @@ import de.jeanlucmakiola.calendula.data.time.TodaySource
import de.jeanlucmakiola.calendula.data.time.currentDate
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.LocalDate
import javax.inject.Inject
@@ -28,35 +29,40 @@ class CalendarHostViewModel @Inject constructor(
) : ViewModel() {
/** Today, kept current across midnight so the shared focus can follow it (#373). */
val today: StateFlow<LocalDate> = todaySource.today.stateWhileSubscribed(
val today: StateFlow<LocalDate> = todaySource.today.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = currentDate(),
)
val defaultView: StateFlow<CalendarView?> = prefs.defaultView.stateWhileSubscribed(
val defaultView: StateFlow<CalendarView?> = prefs.defaultView.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = null,
)
/** Views the top-bar quick-switch pill cycles through, in the user's order (#24). */
val quickSwitchViews: StateFlow<List<CalendarView>> = prefs.quickSwitchConfig
.map { it.cycle }
.stateWhileSubscribed(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = IMPLEMENTED_VIEWS,
)
/** Order of the views in the navigation drawer (#24); every view always shown. */
val drawerViewOrder: StateFlow<List<CalendarView>> = prefs.drawerViewOrder
.stateWhileSubscribed(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = IMPLEMENTED_VIEWS,
)
/** Whether each view's jump-to-today control lives in the top bar, not the FAB (#60). */
val todayButtonInToolbar: StateFlow<Boolean> = prefs.todayButtonInToolbar
.stateWhileSubscribed(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false,
)
@@ -68,8 +74,9 @@ class CalendarHostViewModel @Inject constructor(
* switched off to be rid of.
*/
val dragToReschedule: StateFlow<Boolean> = prefs.dragToReschedule
.stateWhileSubscribed(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false,
)
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.ui
import android.Manifest
import android.content.pm.PackageManager
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -19,6 +20,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
@@ -29,7 +31,6 @@ import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingSteps
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingViewModel
import de.jeanlucmakiola.calendula.ui.onboarding.onboardingSlideTransition
import de.jeanlucmakiola.calendula.ui.onboarding.rememberOnboardingSlideSpec
import de.jeanlucmakiola.floret.android.hasPermission
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
/** What the root is showing: a wizard step, the app, or neither yet. */
@@ -60,7 +61,8 @@ fun RootScreen(
val context = LocalContext.current
var hasPermission by remember {
mutableStateOf(
context.hasPermission(Manifest.permission.READ_CALENDAR)
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR)
== PackageManager.PERMISSION_GRANTED
)
}
@@ -72,7 +74,9 @@ fun RootScreen(
DisposableEffect(lifecycle) {
val obs = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
hasPermission = context.hasPermission(Manifest.permission.READ_CALENDAR)
hasPermission = ContextCompat.checkSelfPermission(
context, Manifest.permission.READ_CALENDAR
) == PackageManager.PERMISSION_GRANTED
// Refresh the contact special-dates mirror on foreground (the
// worker is debounced and no-ops when the feature is off). Gated
// on the permission so users without the opt-in never enqueue it.
@@ -13,7 +13,6 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -67,8 +66,9 @@ class AgendaViewModel @Inject constructor(
* the screen combines it with a per-minute "now" to fade or drop past rows.
*/
val pastEventDisplay: StateFlow<PastEventDisplay> = settingsPrefs.pastEventDisplay
.stateWhileSubscribed(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = PastEventDisplay.SHOW,
)
@@ -79,8 +79,9 @@ class AgendaViewModel @Inject constructor(
* alongside the data state like [pastEventDisplay] rather than re-querying.
*/
val showToday: StateFlow<Boolean> = settingsPrefs.agendaShowToday
.stateWhileSubscribed(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = true,
)
@@ -114,7 +115,7 @@ class AgendaViewModel @Inject constructor(
_window.value = _window.value.copy(anchor = date)
}
}
.stateWhileSubscribed(viewModelScope, todayDate)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), todayDate)
// A transient, in-view override of the saved agenda range. Held in memory
// (not persisted), so it survives view switches and rotation within the
@@ -155,8 +156,9 @@ class AgendaViewModel @Inject constructor(
}
.catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
.stateWhileSubscribed(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = AgendaUiState.Loading,
)
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.ui.calendars
import android.net.Uri
import android.text.format.DateUtils
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
@@ -57,12 +58,10 @@ import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.DialogAmountField
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.locale.relativeTimeAgo
import java.time.LocalDate
// SAF mime filter for the restore picker. Source apps hand `.ics` files out
@@ -225,7 +224,7 @@ private fun BackupContent(
if (exportable.isEmpty()) {
GroupedSectionHeader(stringResource(R.string.calendars_restore_header))
GroupedHint(restoreSummary, style = MaterialTheme.typography.bodySmall)
HintText(restoreSummary)
GroupedRow(
title = stringResource(R.string.calendars_restore_action),
position = Position.Alone,
@@ -235,7 +234,7 @@ private fun BackupContent(
return
}
GroupedHint(stringResource(R.string.calendars_backup_hint), style = MaterialTheme.typography.bodySmall)
HintText(stringResource(R.string.calendars_backup_hint))
GroupedRow(
title = stringResource(R.string.calendars_backup_action),
position = Position.Top,
@@ -274,7 +273,7 @@ private fun BackupContent(
position = Position.Bottom,
onClick = onEditInterval,
)
GroupedHint(backupStatusText(autoBackup.status), style = MaterialTheme.typography.bodySmall)
HintText(backupStatusText(autoBackup.status))
}
}
@@ -313,7 +312,7 @@ private fun ExportCalendarPicker(
onDismiss = onDismiss,
predictiveBack = true,
) {
GroupedHint(stringResource(R.string.calendars_export_hint), style = MaterialTheme.typography.bodySmall)
HintText(stringResource(R.string.calendars_export_hint))
calendars.forEachIndexed { index, calendar ->
val isSelected = calendar.id in selected
GroupedRow(
@@ -379,7 +378,9 @@ private fun backupIntervalLabel(minutes: Long): String {
@Composable
private fun backupStatusText(status: BackupStatus): String {
if (status.lastRun == 0L) return stringResource(R.string.calendars_auto_backup_status_never)
val relative = relativeTimeAgo(status.lastRun)
val relative = DateUtils.getRelativeTimeSpanString(
status.lastRun, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS,
).toString()
return if (status.lastSuccess) {
stringResource(R.string.calendars_auto_backup_status_ok, relative)
} else {
@@ -83,12 +83,12 @@ import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position
@@ -214,7 +214,7 @@ private fun CalendarsList(
visible = visible,
) {
// What the per-calendar / per-account switches below actually do.
GroupedHint(stringResource(R.string.calendars_visibility_hint), style = MaterialTheme.typography.bodySmall)
HintText(stringResource(R.string.calendars_visibility_hint))
// Local (device-only) calendars — one collapsible group. The header's
// "+" adds a calendar; the switch enables/disables them all at once;
@@ -235,7 +235,7 @@ private fun CalendarsList(
onToggleAll = { enabled -> onSetAccountVisible(local.map { it.id }, enabled) },
) {
if (local.isEmpty()) {
GroupedHint(stringResource(R.string.calendars_local_empty), style = MaterialTheme.typography.bodySmall)
HintText(stringResource(R.string.calendars_local_empty))
} else {
local.forEachIndexed { index, calendar ->
val disabled = !calendar.isVisibleInSystem
@@ -282,7 +282,7 @@ private fun CalendarsList(
// collapsible group whose header opens the source app (icon) and toggles
// all of its calendars at once (switch).
GroupedSectionHeader(stringResource(R.string.calendars_synced_header))
GroupedHint(stringResource(R.string.calendars_synced_hint), style = MaterialTheme.typography.bodySmall)
HintText(stringResource(R.string.calendars_synced_hint))
synced
.groupByAccount()
.forEach { group ->
@@ -830,6 +830,16 @@ private fun CalendarGroupMenu(
}
}
@Composable
internal fun HintText(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 4.dp),
)
}
/**
* Pick the app to open for managing a synced calendar's account. The account's
@@ -15,10 +15,10 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
@@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.coroutines.cancellation.CancellationException
@@ -51,8 +52,9 @@ class CalendarsViewModel @Inject constructor(
repository.calendars()
.catch { emit(emptyList()) }
.flowOn(io)
.stateWhileSubscribed(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptyList(),
)
@@ -66,8 +68,9 @@ class CalendarsViewModel @Inject constructor(
.map { backupUiState(it) }
.catch { emit(BackupUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
.stateWhileSubscribed(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = BackupUiState.Loading,
)
@@ -81,8 +84,9 @@ class CalendarsViewModel @Inject constructor(
AutoBackupUiState(enabled, interval, folder, status)
}
.flowOn(io)
.stateWhileSubscribed(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = AutoBackupUiState(),
)
@@ -99,8 +103,9 @@ class CalendarsViewModel @Inject constructor(
if (!enabled) emptySet() else sources.filter { it.isManaged }.map { it.id }.toSet()
}
.flowOn(io)
.stateWhileSubscribed(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptySet(),
)
@@ -34,6 +34,7 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
/**
* The app's single "which calendar" selection list, shared by the event editor
@@ -113,11 +114,17 @@ private fun CalendarPickerGroup(
leading = leading,
)
calendars.forEachIndexed { index, calendar ->
val isSelected = calendar.id == selectedId
GroupedRow(
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle,
checked = calendar.id == selectedId,
selected = isSelected,
leading = { CalendarColorChip(calendar.color) },
trailing = if (isSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = { onSelect(calendar.id) },
)
}
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.ui.common
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
@@ -22,6 +23,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
@@ -30,7 +32,6 @@ import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
import de.jeanlucmakiola.floret.android.hasPermission
import de.jeanlucmakiola.floret.components.OptionCard
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Clock
@@ -74,7 +75,8 @@ enum class DeleteOutcome { Deleted, Gone, NeedsPermission, Failed }
/** Whether this install may write to the calendar; v1.0 installs only hold read access. */
fun Context.canWriteCalendar(): Boolean =
hasPermission(Manifest.permission.WRITE_CALENDAR)
ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_CALENDAR) ==
PackageManager.PERMISSION_GRANTED
/** [deleteEvent], with the provider's failures mapped to a [DeleteOutcome]. */
suspend fun CalendarRepository.tryDeleteEvent(
@@ -14,16 +14,17 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.atOccurrence
import de.jeanlucmakiola.calendula.domain.toDuplicateForm
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.datetime.TimeZone
@@ -49,8 +50,9 @@ class EventActionsViewModel @Inject constructor(
.map { list -> list.associateBy { it.id } }
.catch { emit(emptyMap()) }
.flowOn(io)
.stateWhileSubscribed(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptyMap(),
)
@@ -1,10 +1,10 @@
package de.jeanlucmakiola.calendula.ui.common
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
@@ -45,7 +45,7 @@ class PageStateCache<K, S>(
return entries.getOrPut(key) {
val job = SupervisorJob(scope.coroutineContext[Job])
Entry(
state = load(key).stateWhileSubscribed(scope + job, initial),
state = load(key).stateIn(scope + job, SharingStarted.WhileSubscribed(5_000L), initial),
job = job,
)
}.state
@@ -1,8 +1,12 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -16,16 +20,17 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.floret.components.CustomAmountEditor
import de.jeanlucmakiola.floret.components.CustomExpandRow
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.ReminderUnit
import de.jeanlucmakiola.floret.reminders.minutesOrEmpty
import de.jeanlucmakiola.floret.reminders.reminderOverrideForMinutes
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.agendaRangeWindowSummary
@@ -83,7 +88,7 @@ fun ReminderDefaultPicker(
}
val inherits = current is ReminderOverride.Inherit
val isNone = current is ReminderOverride.None
val selectedMinutes = current.minutesOrEmpty()
val selectedMinutes = (current as? ReminderOverride.Minutes)?.minutes.orEmpty()
// Custom (non-preset) lead times seen this session, so unchecking one keeps
// its row (unchecked) until the picker closes instead of vanishing mid-tap,
// which would strand a hand-entered value with no way to re-check it.
@@ -114,14 +119,24 @@ fun ReminderDefaultPicker(
GroupedRow(
title = stringResource(R.string.reminder_use_default),
position = Position.Top,
checked = inherits,
selected = inherits,
trailing = if (inherits) {
{ SelectedCheck() }
} else {
null
},
onClick = { apply(ReminderOverride.Inherit) },
)
}
GroupedRow(
title = stringResource(R.string.reminder_none),
position = if (allowInherit) Position.Bottom else Position.Alone,
checked = isNone,
selected = isNone,
trailing = if (isNone) {
{ SelectedCheck() }
} else {
null
},
onClick = { apply(ReminderOverride.None) },
)
Spacer(Modifier.height(24.dp))
@@ -137,11 +152,17 @@ fun ReminderDefaultPicker(
onClick = { toggle(minute) },
)
}
CustomExpandRow(
// When expanded, the Custom row connects downward into the editor card
// so the two read as one grouped container (the per-calendar pattern).
GroupedRow(
title = stringResource(R.string.event_edit_reminder_custom),
position = positionOf(rows.size, rowCount),
expanded = customExpanded,
onExpandedChange = { customExpanded = it },
position = if (customExpanded) Position.Top else positionOf(rows.size, rowCount),
onClick = { customExpanded = !customExpanded },
)
AnimatedVisibility(
visible = customExpanded,
enter = expandEnter(),
exit = collapseExit(),
) {
CustomReminderEditor(
amountText = amountText,
@@ -189,6 +210,19 @@ private fun CustomReminderEditor(
)
}
/** A short explanatory paragraph shown under a picker's title, above the rows,
* on the same left margin as the rows themselves. */
@Composable
internal fun PickerDescription(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 8.dp),
)
}
/**
* Agenda-range picker, full-screen. Two grouped lists — the calendar-aligned
* options (today / this week / this month) and the rolling windows (next 7 / 30
@@ -229,11 +263,17 @@ fun AgendaRangePicker(
}
val rangeRow: @Composable (AgendaRange, Position) -> Unit = { option, position ->
val isSelected = option == selected
GroupedRow(
title = agendaRangeLabel(option),
summary = windowSummary(option),
position = position,
checked = option == selected,
selected = isSelected,
trailing = if (isSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = {
onSelect(option)
onDismiss()
@@ -242,7 +282,7 @@ fun AgendaRangePicker(
}
FullScreenPicker(title = title, onDismiss = onDismiss) {
GroupedHint(description, verticalPadding = 8.dp)
PickerDescription(description)
// Calendar-aligned windows.
calendarAligned.forEachIndexed { index, option ->
@@ -255,7 +295,7 @@ fun AgendaRangePicker(
rolling.forEachIndexed { index, option ->
rangeRow(option, positionOf(index, rollingRowCount))
}
CustomExpandRow(
GroupedRow(
title = if (customSelected) {
agendaRangeLabel(selected)
} else {
@@ -264,10 +304,19 @@ fun AgendaRangePicker(
// An unset custom window has no day count yet, so the row stays
// single-line until it does.
summary = if (customSelected) windowSummary(selected) else null,
position = positionOf(rolling.size, rollingRowCount),
expanded = customExpanded,
onExpandedChange = { customExpanded = it },
checked = customSelected,
position = if (customExpanded) Position.Top else positionOf(rolling.size, rollingRowCount),
selected = customSelected,
trailing = if (customSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = { customExpanded = !customExpanded },
)
AnimatedVisibility(
visible = customExpanded,
enter = expandEnter(),
exit = collapseExit(),
) {
CustomDaysEditor(
amountText = amountText,
@@ -345,12 +394,17 @@ fun DurationPicker(
}
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) {
if (description != null) GroupedHint(description, verticalPadding = 8.dp)
if (description != null) PickerDescription(description)
if (inheritLabel != null) {
GroupedRow(
title = inheritLabel,
position = Position.Alone,
checked = inherits,
selected = inherits,
trailing = if (inherits) {
{ SelectedCheck() }
} else {
null
},
onClick = {
onSelect(null)
onDismiss()
@@ -359,22 +413,39 @@ fun DurationPicker(
Spacer(Modifier.height(24.dp))
}
presets.forEachIndexed { index, minute ->
val isSelected = minute == selected
GroupedRow(
title = label(minute),
position = positionOf(index, rowCount),
checked = minute == selected,
selected = isSelected,
trailing = if (isSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = {
onSelect(minute)
onDismiss()
},
)
}
CustomExpandRow(
// The Custom row connects downward into the editor card when expanded, so
// the two read as one grouped container (the shared custom-expand pattern).
GroupedRow(
title = custom?.let { label(it) } ?: stringResource(R.string.event_edit_reminder_custom),
position = positionOf(presets.size, rowCount),
expanded = customExpanded,
onExpandedChange = { customExpanded = it },
checked = custom != null,
position = if (customExpanded) Position.Top else positionOf(presets.size, rowCount),
selected = custom != null,
trailing = if (custom != null) {
{ SelectedCheck() }
} else {
null
},
onClick = { customExpanded = !customExpanded },
)
AnimatedVisibility(
visible = customExpanded,
enter = expandEnter(),
exit = collapseExit(),
) {
CustomDurationEditor(
amountText = amountText,
@@ -22,15 +22,16 @@ import de.jeanlucmakiola.calendula.domain.resolvedZone
import de.jeanlucmakiola.calendula.domain.shiftedByDays
import de.jeanlucmakiola.calendula.domain.shiftedTo
import de.jeanlucmakiola.calendula.domain.toEditForm
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
@@ -187,8 +188,9 @@ class RescheduleViewModel @Inject constructor(
.map { calendars -> calendars.filter { it.allowsEventMove }.map { it.id }.toSet() }
.catch { emit(emptySet<Long>()) }
.flowOn(io)
.stateWhileSubscribed(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptySet(),
)
@@ -42,6 +42,7 @@ import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.locale.currentLocale
import kotlinx.coroutines.Dispatchers
@@ -116,8 +117,13 @@ fun TimeZonePickerDialog(
title = stringResource(R.string.event_edit_timezone_device),
summary = deviceSummary,
position = Position.Alone,
checked = selected == null,
selected = selected == null,
leading = { Icon(Icons.Default.Public, contentDescription = null) },
trailing = if (selected == null) {
{ SelectedCheck() }
} else {
null
},
onClick = { choose(null) },
)
}
@@ -238,7 +244,12 @@ private fun ZoneRow(
title = zone.label,
summary = zoneDescriptor(zone),
position = position,
checked = selected,
selected = selected,
trailing = if (selected) {
{ SelectedCheck() }
} else {
null
},
onClick = onClick,
)
}
@@ -8,6 +8,7 @@ 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.ColumnScope
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
@@ -16,6 +17,7 @@ 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
@@ -56,6 +58,7 @@ import androidx.compose.runtime.setValue
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
@@ -89,7 +92,6 @@ import de.jeanlucmakiola.calendula.domain.RichText
import de.jeanlucmakiola.calendula.domain.TimeZoneOption
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
import de.jeanlucmakiola.floret.components.IconInfoCard
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
@@ -382,7 +384,7 @@ private fun EventDetailContent(
// "When" — date/all-day plus the time range.
val (whenPrimary, whenSecondary) = rememberWhen(instance, TimeZone.currentSystemDefault(), locale)
IconInfoCard(icon = Icons.Default.Schedule, iconContentDescription = null) {
DetailCard(icon = Icons.Default.Schedule, iconContentDescription = null) {
Text(text = whenPrimary, style = MaterialTheme.typography.titleMedium)
if (whenSecondary != null) {
Spacer(Modifier.height(2.dp))
@@ -407,7 +409,7 @@ private fun EventDetailContent(
}
foreignZone?.let { zoneOption ->
Spacer(Modifier.height(gap))
IconInfoCard(
DetailCard(
icon = Icons.Default.Public,
iconContentDescription = stringResource(R.string.event_detail_timezone),
) {
@@ -439,7 +441,7 @@ private fun EventDetailContent(
// Calendar — icon tinted in the calendar colour conveys identity, so no
// separate colour dot is needed.
Spacer(Modifier.height(gap))
IconInfoCard(
DetailCard(
icon = Icons.Default.CalendarMonth,
iconTint = accent,
iconContentDescription = stringResource(R.string.event_detail_calendar),
@@ -457,7 +459,7 @@ private fun EventDetailContent(
val context = LocalContext.current
val locationLabel = stringResource(R.string.event_detail_location)
Spacer(Modifier.height(gap))
IconInfoCard(
DetailCard(
icon = Icons.Default.Place,
iconContentDescription = locationLabel,
) {
@@ -484,7 +486,7 @@ private fun EventDetailContent(
state.description?.let { description ->
val descriptionLabel = stringResource(R.string.event_detail_description)
Spacer(Modifier.height(gap))
IconInfoCard(
DetailCard(
icon = Icons.AutoMirrored.Filled.Notes,
iconContentDescription = descriptionLabel,
// The gesture sits on the card so the icon and padding answer
@@ -503,7 +505,7 @@ private fun EventDetailContent(
// each attendee with their role and reply.
detail.attendees.takeIf { it.isNotEmpty() }?.let { attendees ->
Spacer(Modifier.height(gap))
IconInfoCard(
DetailCard(
icon = Icons.Default.People,
iconContentDescription = stringResource(R.string.event_detail_attendees),
) {
@@ -525,7 +527,7 @@ private fun EventDetailContent(
// Reminders (conditional) — list each lead time before the event.
detail.reminders.takeIf { it.isNotEmpty() }?.let { reminders ->
Spacer(Modifier.height(gap))
IconInfoCard(
DetailCard(
icon = Icons.Default.Notifications,
iconContentDescription = stringResource(R.string.event_detail_reminders),
) {
@@ -545,7 +547,7 @@ private fun EventDetailContent(
// Recurrence (conditional) — humanised from the RRULE.
detail.rrule?.takeIf { it.isNotBlank() }?.let { rrule ->
Spacer(Modifier.height(gap))
IconInfoCard(
DetailCard(
icon = Icons.Default.Repeat,
iconContentDescription = stringResource(R.string.event_detail_recurrence),
) {
@@ -558,6 +560,36 @@ private fun EventDetailContent(
}
}
/** One info card: tonal container, leading icon in the gutter, value to the right. */
@Composable
private fun DetailCard(
icon: ImageVector,
iconContentDescription: String?,
modifier: Modifier = Modifier,
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(
@@ -16,12 +16,12 @@ import de.jeanlucmakiola.calendula.domain.toDuplicateForm
import de.jeanlucmakiola.calendula.ui.common.DeleteOutcome
import de.jeanlucmakiola.calendula.ui.common.tryDeleteEvent
import de.jeanlucmakiola.calendula.ui.common.stageEventShare
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
@@ -30,6 +30,7 @@ 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 kotlinx.coroutines.launch
import kotlinx.datetime.TimeZone
import kotlin.coroutines.cancellation.CancellationException
@@ -75,8 +76,9 @@ class EventDetailViewModel @Inject constructor(
}
}
.flowOn(io)
.stateWhileSubscribed(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = EventDetailUiState.Loading,
)
@@ -33,7 +33,6 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Notes
import androidx.compose.material.icons.filled.Add
import de.jeanlucmakiola.floret.components.IconInfoCard
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import kotlinx.coroutines.launch
import de.jeanlucmakiola.calendula.ui.common.sendEventShare
@@ -161,6 +160,7 @@ import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.OptionCard
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.groupedShape as floretGroupedShape
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
@@ -844,7 +844,7 @@ private fun EventEditContent(
// "When" card: the icon centres on the all-day row (header); the
// start/end rows continue below in the same text column.
IconInfoCard(
EditCard(
icon = Icons.Default.Schedule,
iconContentDescription = null,
header = {
@@ -930,7 +930,7 @@ private fun EventEditContent(
// different one while editing *moves* the event (copy+delete on save,
// since CALENDAR_ID can't be updated in place). Managed special-dates
// events stay locked: the contact sync owns their calendar.
IconInfoCard(
EditCard(
icon = Icons.Default.CalendarMonth,
iconContentDescription = stringResource(R.string.event_detail_calendar),
iconTint = accent,
@@ -961,7 +961,7 @@ private fun EventEditContent(
// rest unfold behind the "more fields" button below.
OptionalFormSection(visible = EventFormField.Timezone in state.visibleFields) {
Spacer(Modifier.height(gap))
IconInfoCard(
EditCard(
icon = Icons.Default.Public,
iconContentDescription = null,
onClick = { showTimezonePicker = true },
@@ -1009,11 +1009,10 @@ private fun EventEditContent(
OptionalFormSection(visible = EventFormField.Location in state.visibleFields) {
Spacer(Modifier.height(gap))
IconInfoCard(
EditCard(
icon = Icons.Default.Place,
iconContentDescription = stringResource(R.string.event_detail_location),
iconAlignment = Alignment.Top,
iconTopPadding = ((firstLineHeight() - 24.dp) / 2).coerceAtLeast(0.dp),
iconAtTop = true,
) {
Row(verticalAlignment = Alignment.Top) {
Column(modifier = Modifier.weight(1f)) {
@@ -1079,11 +1078,10 @@ private fun EventEditContent(
OptionalFormSection(visible = EventFormField.Description in state.visibleFields) {
Spacer(Modifier.height(gap))
IconInfoCard(
EditCard(
icon = Icons.AutoMirrored.Filled.Notes,
iconContentDescription = stringResource(R.string.event_detail_description),
iconAlignment = Alignment.Top,
iconTopPadding = ((firstLineHeight() - 24.dp) / 2).coerceAtLeast(0.dp),
iconAtTop = true,
) {
InlineField(
value = form.description,
@@ -1119,7 +1117,7 @@ private fun EventEditContent(
OptionalFormSection(visible = EventFormField.Recurrence in state.visibleFields) {
Spacer(Modifier.height(gap))
IconInfoCard(
EditCard(
icon = Icons.Default.Repeat,
iconContentDescription = null,
onClick = if (locked) null else ({ showRecurrencePicker = true }),
@@ -1159,7 +1157,7 @@ private fun EventEditContent(
OptionalFormSection(visible = EventFormField.Availability in state.visibleFields) {
Spacer(Modifier.height(gap))
IconInfoCard(
EditCard(
icon = Icons.Default.EventAvailable,
iconContentDescription = null,
header = {
@@ -1187,7 +1185,7 @@ private fun EventEditContent(
OptionalFormSection(visible = EventFormField.Visibility in state.visibleFields) {
Spacer(Modifier.height(gap))
IconInfoCard(
EditCard(
icon = Icons.Default.Lock,
iconContentDescription = null,
onClick = { showVisibilityPicker = true },
@@ -1221,7 +1219,7 @@ private fun EventEditContent(
// The swatch the event will paint with: its own colour, else the
// calendar's. The Palette icon takes that colour as a preview.
val swatch = form.color ?: selectedCalendar?.color
IconInfoCard(
EditCard(
icon = Icons.Default.Palette,
iconContentDescription = stringResource(R.string.event_edit_color),
iconTint = if (colorSupported && swatch != null) {
@@ -1674,7 +1672,12 @@ private fun RecurrencePickerDialog(
GroupedRow(
title = stringResource(R.string.event_edit_recurrence_none),
position = positionOf(0, rowCount),
checked = current == null,
selected = current == null,
trailing = if (current == null) {
{ SelectedCheck() }
} else {
null
},
onClick = { onSelect(null) },
)
RecurrenceFreq.entries.forEachIndexed { index, entry ->
@@ -1682,14 +1685,24 @@ private fun RecurrencePickerDialog(
title = stringResource(recurrencePresetLabel(entry)),
summary = nextOccurrencesText(SimpleRecurrence(entry), startDate, locale),
position = positionOf(index + 1, rowCount),
checked = isPlainPreset && parsed?.freq == entry,
selected = isPlainPreset && parsed?.freq == entry,
trailing = if (isPlainPreset && parsed?.freq == entry) {
{ SelectedCheck() }
} else {
null
},
onClick = { onSelect(SimpleRecurrence(entry).toRRule()) },
)
}
GroupedRow(
title = stringResource(R.string.event_edit_recurrence_custom),
position = positionOf(rowCount - 1, rowCount),
checked = current != null && !isPlainPreset,
selected = current != null && !isPlainPreset,
trailing = if (current != null && !isPlainPreset) {
{ SelectedCheck() }
} else {
null
},
onClick = { customMode = true },
)
} else {
@@ -1818,14 +1831,24 @@ private fun RecurrencePickerDialog(
GroupedRow(
title = stringResource(R.string.event_edit_recurrence_end_never),
position = Position.Top,
checked = endMode == RecurrenceEndMode.Never,
selected = endMode == RecurrenceEndMode.Never,
trailing = if (endMode == RecurrenceEndMode.Never) {
{ SelectedCheck() }
} else {
null
},
onClick = { endMode = RecurrenceEndMode.Never },
)
GroupedRow(
title = stringResource(R.string.event_edit_recurrence_end_until),
summary = untilSummary,
position = Position.Middle,
checked = endMode == RecurrenceEndMode.Until,
selected = endMode == RecurrenceEndMode.Until,
trailing = if (endMode == RecurrenceEndMode.Until) {
{ SelectedCheck() }
} else {
null
},
onClick = {
endMode = RecurrenceEndMode.Until
showUntilPicker = true
@@ -1838,7 +1861,12 @@ private fun RecurrencePickerDialog(
} else {
Position.Bottom
},
checked = endMode == RecurrenceEndMode.Count,
selected = endMode == RecurrenceEndMode.Count,
trailing = if (endMode == RecurrenceEndMode.Count) {
{ SelectedCheck() }
} else {
null
},
onClick = { endMode = RecurrenceEndMode.Count },
)
// Animated for the same reason the weekday card is: this is the
@@ -2347,6 +2375,85 @@ private fun accessLevelSummary(level: AccessLevel): Int = when (level) {
@Composable
private fun reminderLabel(minutes: Int): String = reminderLeadTimeLabel(minutes)
/**
* One info card mirroring the detail screen's DetailCard: tonal container,
* leading icon in the gutter, value to the right. Optionally clickable as a
* whole (calendar picker).
*/
@Composable
private fun EditCard(
icon: ImageVector,
iconContentDescription: String?,
iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
onClick: (() -> Unit)? = null,
// Multiline-input cards align the icon with the field's first text line.
iconAtTop: Boolean = false,
// Tall cards pass their first row here: the icon centres on it, and
// [content] continues below, indented to the same text column.
header: (@Composable ColumnScope.() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
val shape = RoundedCornerShape(16.dp)
val color = MaterialTheme.colorScheme.surfaceContainerHigh
val inner: @Composable () -> Unit = {
if (header != null) {
Column(modifier = Modifier.padding(16.dp)) {
Row(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 = header)
}
Column(
modifier = Modifier.padding(start = 40.dp),
content = content,
)
}
} else {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = if (iconAtTop) Alignment.Top else Alignment.CenterVertically,
) {
// A top-aligned icon centres on the first text line, whatever
// that line measures at the user's font scale.
val iconOffset = if (iconAtTop) {
((firstLineHeight() - 24.dp) / 2).coerceAtLeast(0.dp)
} else {
0.dp
}
Icon(
imageVector = icon,
contentDescription = iconContentDescription,
tint = iconTint,
modifier = Modifier
.padding(top = iconOffset)
.size(24.dp),
)
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f), content = content)
}
}
}
if (onClick != null) {
Surface(
onClick = onClick,
color = color,
shape = shape,
modifier = Modifier.fillMaxWidth(),
) { inner() }
} else {
Surface(
color = color,
shape = shape,
modifier = Modifier.fillMaxWidth(),
) { inner() }
}
}
/**
* "2:00 PM – 3:00 PM" in [zone], honouring the user's 12/24-hour setting. Collapses
* to one time for an instant event, which shouldn't read as a range against
@@ -41,7 +41,6 @@ import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_O
import de.jeanlucmakiola.calendula.ui.common.DeleteOutcome
import de.jeanlucmakiola.calendula.ui.common.tryDeleteEvent
import de.jeanlucmakiola.calendula.ui.common.stageEventShare
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -339,8 +338,9 @@ class EventEditViewModel @Inject constructor(
allowColorOnUnsupportedCalendars = external.allowColorOnUnsupported,
)
}
.stateWhileSubscribed(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = null,
)
@@ -9,14 +9,15 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
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
@@ -44,8 +45,9 @@ class FilterViewModel @Inject constructor(
}
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
.stateWhileSubscribed(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = FilterUiState.Loading,
)
@@ -18,11 +18,11 @@ import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
import de.jeanlucmakiola.calendula.ui.week.coversDay
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
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
@@ -33,6 +33,7 @@ import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.map
import kotlin.math.abs
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
@@ -68,22 +69,25 @@ class MonthViewModel @Inject constructor(
/** Whether to fade events that have already finished (display concern only). */
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
.stateWhileSubscribed(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false,
)
/** Whether to show the calendar-week number gutter (#25; display only). */
val showWeekNumbers: StateFlow<Boolean> = settingsPrefs.showWeekNumbers
.stateWhileSubscribed(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false,
)
/** How the grid is laid out and navigated (#38, #53). */
val viewStyle: StateFlow<MonthViewStyle> = settingsPrefs.monthViewStyle
.stateWhileSubscribed(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = MonthViewStyle.Paged,
)
@@ -99,7 +103,7 @@ class MonthViewModel @Inject constructor(
lastToday = date
if (old != date) followToday(old, date)
}
.stateWhileSubscribed(viewModelScope, todayDate)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), todayDate)
private val _month = MutableStateFlow(YearMonth(todayDate.year, todayDate.month))
val month: StateFlow<YearMonth> = _month
@@ -120,8 +124,9 @@ class MonthViewModel @Inject constructor(
month(ym, ws).filterNot { it is MonthUiState.Loading }
}
.catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) }
.stateWhileSubscribed(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = MonthUiState.Loading,
)
@@ -223,8 +228,9 @@ class MonthViewModel @Inject constructor(
.onEach { if (it is ContinuousMonthUiState.Success) widenLoadedWindow() }
.catch { emit(ContinuousMonthUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
.stateWhileSubscribed(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = ContinuousMonthUiState.Loading,
)
@@ -10,14 +10,17 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CloudOff
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
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.floret.components.BenefitRow
import de.jeanlucmakiola.floret.components.OnboardingPrimaryButton
import de.jeanlucmakiola.floret.components.OnboardingScaffold
import de.jeanlucmakiola.floret.components.OnboardingSpace
@@ -50,10 +53,15 @@ internal fun BackupStep(
topSpacing = OnboardingSpace.lg,
hero = { IconHero(Icons.Filled.CloudOff) },
actions = {
OnboardingPrimaryButton(
text = stringResource(R.string.onboarding_backup_enable_button),
Button(
onClick = { runCatching { pickFolder.launch(null) } },
)
modifier = Modifier.fillMaxWidth().height(56.dp),
) {
Text(
text = stringResource(R.string.onboarding_backup_enable_button),
style = MaterialTheme.typography.titleMedium,
)
}
TextButton(
onClick = onSkip,
modifier = Modifier.fillMaxWidth(),
@@ -61,9 +69,20 @@ internal fun BackupStep(
Text(stringResource(R.string.onboarding_backup_skip_button))
}
},
headline = stringResource(R.string.onboarding_backup_title),
supporting = stringResource(R.string.onboarding_backup_body),
) {
Text(
text = stringResource(R.string.onboarding_backup_title),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.onboarding_backup_body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(OnboardingSpace.lg))
BenefitRow(
@@ -9,6 +9,7 @@ import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.CloudSync
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
@@ -29,7 +30,6 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
import de.jeanlucmakiola.calendula.ui.calendars.CalendarEditor
import de.jeanlucmakiola.calendula.ui.settings.openUrl
import de.jeanlucmakiola.floret.components.BenefitRow
import de.jeanlucmakiola.floret.components.OnboardingPrimaryButton
import de.jeanlucmakiola.floret.components.OnboardingScaffold
import de.jeanlucmakiola.floret.components.OnboardingSpace
@@ -108,13 +108,18 @@ internal fun CalendarsStep(
topSpacing = OnboardingSpace.lg,
hero = { IconHero(Icons.Filled.CalendarMonth) },
actions = {
OnboardingPrimaryButton(
text = stringResource(R.string.onboarding_calendars_local_button),
Button(
onClick = {
onClearCreation()
creating = true
},
)
modifier = Modifier.fillMaxWidth().height(56.dp),
) {
Text(
text = stringResource(R.string.onboarding_calendars_local_button),
style = MaterialTheme.typography.titleMedium,
)
}
OutlinedButton(
onClick = { openUrl(context, DAVX5_URL) },
modifier = Modifier.fillMaxWidth().height(56.dp),
@@ -128,9 +133,20 @@ internal fun CalendarsStep(
Text(stringResource(R.string.onboarding_calendars_skip_button))
}
},
headline = stringResource(R.string.onboarding_calendars_title),
supporting = stringResource(R.string.onboarding_calendars_body),
) {
Text(
text = stringResource(R.string.onboarding_calendars_title),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.onboarding_calendars_body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
if (creation is CalendarCreation.Failed) {
Spacer(Modifier.height(12.dp))
Text(
@@ -173,14 +189,30 @@ private fun CalendarCreated(
topSpacing = OnboardingSpace.lg,
hero = { IconHero(Icons.Filled.CheckCircle) },
actions = {
OnboardingPrimaryButton(
text = stringResource(R.string.onboarding_calendars_created_continue),
Button(
onClick = onContinue,
)
modifier = Modifier.fillMaxWidth().height(56.dp),
) {
Text(
text = stringResource(R.string.onboarding_calendars_created_continue),
style = MaterialTheme.typography.titleMedium,
)
}
},
headline = stringResource(R.string.onboarding_calendars_created_title, name),
supporting = stringResource(R.string.onboarding_calendars_created_body),
) {
Text(
text = stringResource(R.string.onboarding_calendars_created_title, name),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.onboarding_calendars_created_body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(OnboardingSpace.lg))
BenefitRow(
@@ -1,10 +1,17 @@
package de.jeanlucmakiola.calendula.ui.onboarding
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.Button
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 androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.floret.components.OnboardingPrimaryButton
import de.jeanlucmakiola.floret.components.OnboardingScaffold
import de.jeanlucmakiola.floret.components.OnboardingSpace
@@ -27,12 +34,28 @@ internal fun DoneStep(
topSpacing = OnboardingSpace.lg,
hero = { BrandHero(HeroBadge.Done) },
actions = {
OnboardingPrimaryButton(
text = stringResource(R.string.onboarding_done_button),
Button(
onClick = onFinished,
)
modifier = Modifier.fillMaxWidth().height(56.dp),
) {
Text(
text = stringResource(R.string.onboarding_done_button),
style = MaterialTheme.typography.titleMedium,
)
}
},
headline = stringResource(R.string.onboarding_done_title),
supporting = stringResource(R.string.onboarding_done_body),
)
) {
Text(
text = stringResource(R.string.onboarding_done_title),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.onboarding_done_body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
@@ -24,7 +25,6 @@ import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.OnboardingPrimaryButton
import de.jeanlucmakiola.floret.components.OnboardingScaffold
import de.jeanlucmakiola.floret.components.OnboardingSpace
import de.jeanlucmakiola.floret.components.Position
@@ -65,17 +65,24 @@ internal fun HiddenCalendarsStep(
scrollingActions = true,
hero = { IconHero(Icons.Filled.VisibilityOff) },
actions = {
OnboardingPrimaryButton(
text = stringResource(
if (anyVisible) {
R.string.onboarding_view_continue_button
} else {
R.string.onboarding_hidden_switch_all_button
},
),
Button(
onClick = if (anyVisible) onFinished else onSwitchAllOn,
modifier = Modifier.padding(horizontal = GroupedListInset),
)
modifier = Modifier
.padding(horizontal = GroupedListInset)
.fillMaxWidth()
.height(56.dp),
) {
Text(
text = stringResource(
if (anyVisible) {
R.string.onboarding_view_continue_button
} else {
R.string.onboarding_hidden_switch_all_button
},
),
style = MaterialTheme.typography.titleMedium,
)
}
// Only while nothing is on: once something is, the step has been
// answered and the one button left to press says so.
if (!anyVisible) {
@@ -19,7 +19,6 @@ import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch
import de.jeanlucmakiola.calendula.domain.orderedForManager
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -93,8 +92,9 @@ class OnboardingViewModel @Inject constructor(
list.catch { emit(null) }.flowOn(io)
}
}
.stateWhileSubscribed(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = null,
)
@@ -183,8 +183,9 @@ class OnboardingViewModel @Inject constructor(
*/
val hiddenCalendars: StateFlow<List<CalendarSource>> = calendars
.map { it.orEmpty().orderedForManager() }
.stateWhileSubscribed(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptyList(),
)
@@ -231,8 +232,9 @@ class OnboardingViewModel @Inject constructor(
doneShown = closing.doneShown,
)
}
}.stateWhileSubscribed(
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = null,
)
@@ -242,8 +244,9 @@ class OnboardingViewModel @Inject constructor(
prefs.monthViewStyle,
prefs.weekStart,
::ViewChoice,
).stateWhileSubscribed(
).stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = null,
)
@@ -1,7 +1,6 @@
package de.jeanlucmakiola.calendula.ui.onboarding
import de.jeanlucmakiola.floret.components.BenefitRow
import de.jeanlucmakiola.floret.components.OnboardingPrimaryButton
import de.jeanlucmakiola.floret.components.OnboardingScaffold
import de.jeanlucmakiola.floret.components.OnboardingSpace
@@ -14,10 +13,16 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -28,6 +33,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
private val CALENDAR_PERMISSIONS = arrayOf(
@@ -86,15 +93,37 @@ private fun RationaleContent(
topSpacing = OnboardingSpace.lg,
hero = { BrandHero() },
actions = {
OnboardingPrimaryButton(
text = stringResource(R.string.permission_request_button),
Button(
onClick = onRequest,
trailingIcon = Icons.AutoMirrored.Filled.ArrowForward,
)
modifier = Modifier.fillMaxWidth().height(56.dp),
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
) {
Text(
text = stringResource(R.string.permission_request_button),
style = MaterialTheme.typography.titleMedium,
)
Spacer(Modifier.width(OnboardingSpace.xs))
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = null,
modifier = Modifier.size(20.dp),
)
}
},
headline = stringResource(R.string.permission_rationale_title),
supporting = stringResource(R.string.permission_rationale_body),
) {
Text(
text = stringResource(R.string.permission_rationale_title),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.permission_rationale_body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(OnboardingSpace.lg))
BenefitRow(
@@ -123,8 +152,7 @@ private fun DeniedContent(
progress = progress,
hero = { BrandHero(HeroBadge.Denied) },
actions = {
OnboardingPrimaryButton(
text = stringResource(R.string.permission_open_settings_button),
Button(
onClick = {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", context.packageName, null)
@@ -132,7 +160,13 @@ private fun DeniedContent(
}
context.startActivity(intent)
},
)
modifier = Modifier.fillMaxWidth().height(56.dp),
) {
Text(
text = stringResource(R.string.permission_open_settings_button),
style = MaterialTheme.typography.titleMedium,
)
}
TextButton(
onClick = onRetry,
modifier = Modifier.fillMaxWidth(),
@@ -140,7 +174,18 @@ private fun DeniedContent(
Text(stringResource(R.string.permission_retry_button))
}
},
headline = stringResource(R.string.permission_denied_title),
supporting = stringResource(R.string.permission_denied_body),
)
) {
Text(
text = stringResource(R.string.permission_denied_title),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.permission_denied_body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
@@ -1,7 +1,6 @@
package de.jeanlucmakiola.calendula.ui.onboarding
import de.jeanlucmakiola.floret.components.BenefitRow
import de.jeanlucmakiola.floret.components.OnboardingPrimaryButton
import de.jeanlucmakiola.floret.components.OnboardingScaffold
import de.jeanlucmakiola.floret.components.OnboardingSpace
@@ -15,11 +14,15 @@ import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.NotificationsActive
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
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
/**
@@ -50,8 +53,7 @@ fun ReminderStep(
topSpacing = OnboardingSpace.lg,
hero = { IconHero(Icons.Filled.NotificationsActive) },
actions = {
OnboardingPrimaryButton(
text = stringResource(R.string.reminder_onboarding_enable_button),
Button(
onClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
@@ -59,7 +61,13 @@ fun ReminderStep(
onFinished(true)
}
},
)
modifier = Modifier.fillMaxWidth().height(56.dp),
) {
Text(
text = stringResource(R.string.reminder_onboarding_enable_button),
style = MaterialTheme.typography.titleMedium,
)
}
TextButton(
onClick = { onFinished(false) },
modifier = Modifier.fillMaxWidth(),
@@ -67,9 +75,20 @@ fun ReminderStep(
Text(stringResource(R.string.reminder_onboarding_skip_button))
}
},
headline = stringResource(R.string.reminder_onboarding_title),
supporting = stringResource(R.string.reminder_onboarding_body),
) {
Text(
text = stringResource(R.string.reminder_onboarding_title),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.reminder_onboarding_body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(OnboardingSpace.lg))
BenefitRow(
@@ -19,6 +19,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaViewPreview
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.LocalMultiDayCount
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
import de.jeanlucmakiola.calendula.ui.common.icon
import de.jeanlucmakiola.calendula.ui.common.label
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
@@ -26,11 +27,11 @@ import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.calendula.ui.month.descriptionRes
import de.jeanlucmakiola.calendula.ui.month.labelRes
import de.jeanlucmakiola.calendula.ui.week.WeekViewPreview
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.OnboardingScaffold
import de.jeanlucmakiola.floret.components.OnboardingSpace
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.ViewPreviewFrame
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.locale.currentLocale
@@ -76,10 +77,11 @@ internal fun ViewStep(
}
}
IMPLEMENTED_VIEWS.forEachIndexed { index, view ->
val isSelected = view == choice.defaultView
GroupedRow(
title = view.label(),
position = positionOf(index, IMPLEMENTED_VIEWS.size),
checked = view == choice.defaultView,
selected = isSelected,
leading = {
Icon(
imageVector = view.icon,
@@ -87,6 +89,11 @@ internal fun ViewStep(
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
trailing = if (isSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = { onSelect(view) },
)
}
@@ -120,12 +127,18 @@ internal fun MonthStyleStep(
StepPreview(selected = choice.monthViewStyle) { style ->
MonthStylePreview(style = style, weekStart = weekStart, height = PREVIEW_HEIGHT)
}
GroupedHint(stringResource(choice.monthViewStyle.descriptionRes), verticalPadding = 8.dp)
PickerDescription(stringResource(choice.monthViewStyle.descriptionRes))
options.forEachIndexed { index, style ->
val isSelected = style == choice.monthViewStyle
GroupedRow(
title = stringResource(style.labelRes),
position = positionOf(index, options.size),
checked = style == choice.monthViewStyle,
selected = isSelected,
trailing = if (isSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = { onSelect(style) },
)
}
@@ -1,12 +1,19 @@
package de.jeanlucmakiola.calendula.ui.onboarding
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
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 androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.floret.components.OnboardingPrimaryButton
import de.jeanlucmakiola.floret.components.OnboardingScaffold
import de.jeanlucmakiola.floret.components.OnboardingSpace
@@ -31,12 +38,28 @@ internal fun VisibilityStep(
topSpacing = OnboardingSpace.lg,
hero = { IconHero(Icons.Filled.VisibilityOff) },
actions = {
OnboardingPrimaryButton(
text = stringResource(R.string.onboarding_visibility_button),
Button(
onClick = onFinished,
)
modifier = Modifier.fillMaxWidth().height(56.dp),
) {
Text(
text = stringResource(R.string.onboarding_visibility_button),
style = MaterialTheme.typography.titleMedium,
)
}
},
headline = stringResource(R.string.calendars_visibility_notice_title),
supporting = stringResource(R.string.calendars_visibility_notice_message),
)
) {
Text(
text = stringResource(R.string.calendars_visibility_notice_title),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.calendars_visibility_notice_message),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.ui.search
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -71,6 +72,7 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
@@ -80,7 +82,6 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.SearchHit
import de.jeanlucmakiola.calendula.domain.SearchMonth
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.floret.android.hasPermission
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.floret.identity.fadeThrough
@@ -165,7 +166,10 @@ fun SearchScreen(
pendingWrite = null
}
val requireWrite: (() -> Unit) -> Unit = { action ->
val granted = context.hasPermission(Manifest.permission.WRITE_CALENDAR)
val granted = ContextCompat.checkSelfPermission(
context,
Manifest.permission.WRITE_CALENDAR,
) == PackageManager.PERMISSION_GRANTED
if (granted) {
action()
} else {
@@ -8,13 +8,13 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventSearch
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.SearchResults
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
@@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.datetime.TimeZone
@@ -119,7 +120,7 @@ class SearchViewModel @Inject constructor(
}
.catch { emit(SearchUiState.Idle) }
.flowOn(io)
.stateWhileSubscribed(viewModelScope, SearchUiState.Idle)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), SearchUiState.Idle)
fun setQuery(value: String) {
if (value != _query.value) _selection.value = emptySet()
@@ -51,18 +51,18 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.appname.LauncherName
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
import de.jeanlucmakiola.calendula.ui.theme.BundledFont
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSwitchRow
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf
/**
@@ -127,12 +127,17 @@ internal fun AppearanceScreen(
null
},
)
GroupedSwitchRow(
GroupedRow(
title = stringResource(R.string.settings_soften_colors),
checked = state.softenColors,
onCheckedChange = viewModel::setSoftenColors,
summary = stringResource(R.string.settings_soften_colors_summary),
position = Position.Bottom,
trailing = {
Switch(
checked = state.softenColors,
onCheckedChange = viewModel::setSoftenColors,
)
},
onClick = { viewModel.setSoftenColors(!state.softenColors) },
)
Spacer(Modifier.height(16.dp))
@@ -204,7 +209,7 @@ internal fun AppearanceScreen(
val systemDark = isSystemInDarkTheme()
OptionPicker(
title = stringResource(R.string.settings_theme),
header = { GroupedHint(stringResource(R.string.settings_theme_hint), verticalPadding = 8.dp) },
header = { PickerDescription(stringResource(R.string.settings_theme_hint)) },
predictiveBack = true,
options = ThemeMode.entries,
selected = state.themeMode,
@@ -402,7 +407,7 @@ private fun FontOptionRow(
GroupedRow(
title = label,
position = position,
checked = selected,
selected = selected,
leading = {
if (preview != null) {
Text(
@@ -414,6 +419,11 @@ private fun FontOptionRow(
Icon(imageVector = leadingIcon, contentDescription = null)
}
},
trailing = if (selected) {
{ SelectedCheck() }
} else {
null
},
onClick = onClick,
)
}
@@ -5,10 +5,13 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -26,10 +29,7 @@ import de.jeanlucmakiola.calendula.ui.common.durationLabel
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.ExpandIcon
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSwitchRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.collapseExit
@@ -56,15 +56,13 @@ internal fun EventFormScreen(
predictiveBack = true,
visible = visible,
) {
GroupedHint(stringResource(R.string.settings_form_fields_hint))
SettingsHint(stringResource(R.string.settings_form_fields_hint))
Spacer(Modifier.height(8.dp))
val fields = EventFormField.entries
fields.forEachIndexed { index, field ->
val checked = field in state.defaultFormFields
GroupedSwitchRow(
GroupedRow(
title = stringResource(eventFormFieldLabel(field)),
checked = checked,
onCheckedChange = { viewModel.setFormFieldDefault(field, it) },
position = positionOf(index, fields.size),
// The same icon the field carries in the new-event form.
leading = {
@@ -74,15 +72,20 @@ internal fun EventFormScreen(
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
trailing = {
Switch(
checked = checked,
onCheckedChange = { viewModel.setFormFieldDefault(field, it) },
)
},
onClick = { viewModel.setFormFieldDefault(field, !checked) },
)
}
// Auto-focus the title on a new event (#10), on by default.
Spacer(Modifier.height(24.dp))
GroupedSwitchRow(
GroupedRow(
title = stringResource(R.string.settings_autofocus_title),
checked = state.autofocusEventTitle,
onCheckedChange = { viewModel.setAutofocusEventTitle(it) },
summary = stringResource(R.string.settings_autofocus_title_hint),
position = Position.Alone,
leading = {
@@ -92,6 +95,13 @@ internal fun EventFormScreen(
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
trailing = {
Switch(
checked = state.autofocusEventTitle,
onCheckedChange = { viewModel.setAutofocusEventTitle(it) },
)
},
onClick = { viewModel.setAutofocusEventTitle(!state.autofocusEventTitle) },
)
// How long a new timed event opens (#54), globally and per calendar —
@@ -117,7 +127,13 @@ internal fun EventFormScreen(
// Expanded, the header opens the calendar run below it and the
// two read as one container (the shared expand pattern).
position = if (durationSectionExpanded) Position.Top else Position.Bottom,
trailing = { ExpandIcon(durationSectionExpanded) },
trailing = {
Icon(
imageVector = if (durationSectionExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
onClick = { durationSectionExpanded = !durationSectionExpanded },
)
AnimatedVisibility(
@@ -149,12 +165,21 @@ internal fun EventFormScreen(
// Per-event colour on calendars that publish no colour set (some
// CalDAV); off by default, since it may not survive their next sync.
Spacer(Modifier.height(24.dp))
GroupedSwitchRow(
GroupedRow(
title = stringResource(R.string.settings_color_unsupported),
checked = state.allowColorOnUnsupportedCalendars,
onCheckedChange = { viewModel.setAllowColorOnUnsupportedCalendars(it) },
summary = stringResource(R.string.settings_color_unsupported_hint),
position = Position.Alone,
trailing = {
Switch(
checked = state.allowColorOnUnsupportedCalendars,
onCheckedChange = { viewModel.setAllowColorOnUnsupportedCalendars(it) },
)
},
onClick = {
viewModel.setAllowColorOnUnsupportedCalendars(
!state.allowColorOnUnsupportedCalendars,
)
},
)
}
@@ -6,14 +6,16 @@ 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.common.PickerDescription
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.calendula.ui.month.descriptionRes
import de.jeanlucmakiola.calendula.ui.month.labelRes
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.OptionRows
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.ViewPreviewFrame
import de.jeanlucmakiola.floret.components.positionOf
import kotlinx.datetime.DayOfWeek
/**
@@ -27,7 +29,7 @@ import kotlinx.datetime.DayOfWeek
*
* Under the preview sits the selected style's own one-line blurb, then the
* family's standard picker shape: connected single-line grouped rows with a
* tonal highlight and a check on the current one.
* tonal highlight and [SelectedCheck] on the current one.
*/
@Composable
internal fun MonthViewStylePicker(
@@ -56,15 +58,23 @@ internal fun MonthViewStylePicker(
// The selected style's own blurb lives here, under its preview, rather
// than on every row — the rows stay single-line and dense, and the words
// describe exactly what is being shown above.
GroupedHint(stringResource(selected.descriptionRes), verticalPadding = 8.dp)
// Applies straight away; the preview above is the confirmation, so
// there is nothing to dismiss for.
OptionRows(
options = options,
selected = selected,
label = { stringResource(it.labelRes) },
onSelect = onSelect,
)
PickerDescription(stringResource(selected.descriptionRes))
options.forEachIndexed { index, style ->
val isSelected = style == selected
GroupedRow(
title = stringResource(style.labelRes),
position = positionOf(index, options.size),
selected = isSelected,
trailing = if (isSelected) {
{ SelectedCheck() }
} else {
null
},
// Applies straight away; the preview above is the confirmation,
// so there is nothing to dismiss for.
onClick = { onSelect(style) },
)
}
}
}
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.ui.settings
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
@@ -14,8 +15,11 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
@@ -26,6 +30,7 @@ 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.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
@@ -37,17 +42,13 @@ import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
import de.jeanlucmakiola.calendula.ui.common.durationLabel
import de.jeanlucmakiola.floret.android.hasPermission
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.ExpandIcon
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSwitchRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.minutesOrEmpty
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
import kotlinx.datetime.LocalTime
@@ -72,7 +73,9 @@ internal fun NotificationsScreen(
viewModel.setRemindersEnabled(enabled)
val needsPermission = enabled &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
!context.hasPermission(Manifest.permission.POST_NOTIFICATIONS)
ContextCompat.checkSelfPermission(
context, Manifest.permission.POST_NOTIFICATIONS,
) != PackageManager.PERMISSION_GRANTED
if (needsPermission) {
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
@@ -92,12 +95,14 @@ internal fun NotificationsScreen(
predictiveBack = true,
visible = visible,
) {
GroupedSwitchRow(
GroupedRow(
title = stringResource(R.string.settings_reminders),
checked = state.remindersEnabled,
onCheckedChange = toggleReminders,
summary = stringResource(R.string.settings_reminders_hint),
position = Position.Top,
trailing = {
Switch(checked = state.remindersEnabled, onCheckedChange = toggleReminders)
},
onClick = { toggleReminders(!state.remindersEnabled) },
)
GroupedRow(
title = stringResource(R.string.settings_default_reminder),
@@ -157,7 +162,13 @@ internal fun NotificationsScreen(
title = stringResource(R.string.settings_calendar_reminders_title),
summary = stringResource(R.string.settings_calendar_reminders_hint),
position = Position.Alone,
trailing = { ExpandIcon(calendarSectionExpanded) },
trailing = {
Icon(
imageVector = if (calendarSectionExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
onClick = { calendarSectionExpanded = !calendarSectionExpanded },
)
AnimatedVisibility(
@@ -192,7 +203,13 @@ internal fun NotificationsScreen(
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
position = if (expanded) Position.Top else Position.Alone,
leading = { CalendarColorChip(calendar.color) },
trailing = { ExpandIcon(expanded) },
trailing = {
Icon(
imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
onClick = {
expandedCalendars = if (expanded) {
expandedCalendars - calendar.id
@@ -246,7 +263,7 @@ internal fun NotificationsScreen(
presets = REMINDER_PRESETS,
selected = state.defaultReminderMinutes.toReminderChoice(),
allowInherit = false,
onSelect = { viewModel.setDefaultReminderMinutes(it.minutesOrEmpty()) },
onSelect = { viewModel.setDefaultReminderMinutes(it.toMinutesList()) },
onDismiss = { showDefaultReminder = false },
)
}
@@ -256,7 +273,7 @@ internal fun NotificationsScreen(
presets = ALLDAY_REMINDER_PRESETS,
selected = state.defaultAllDayReminderMinutes.toReminderChoice(),
allowInherit = false,
onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.minutesOrEmpty()) },
onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.toMinutesList()) },
onDismiss = { showAllDayReminder = false },
leadTimeSummary = allDayFiringTimeSummary(state.allDayReminderTimeMinutes),
)
@@ -315,6 +332,10 @@ private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
private fun List<Int>.toReminderChoice(): ReminderOverride =
if (isEmpty()) ReminderOverride.None else ReminderOverride.Minutes(this)
/** A picked choice as global-default minutes (Inherit isn't offered for globals). */
private fun ReminderOverride.toMinutesList(): List<Int> =
(this as? ReminderOverride.Minutes)?.minutes ?: emptyList()
/** Row summary for a calendar: its override, or the inherited global default. */
@Composable
private fun calendarOverrideSummary(
@@ -27,9 +27,10 @@ import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDayHeader
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.OptionRows
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import kotlinx.datetime.LocalDate
@@ -79,13 +80,21 @@ internal fun PastEventsPicker(
}
}
}
GroupedHint(stringResource(R.string.settings_past_events_hint), verticalPadding = 8.dp)
OptionRows(
options = options,
selected = selected,
label = { stringResource(pastEventDisplayLabelRes(it)) },
onSelect = onSelect,
)
PickerDescription(stringResource(R.string.settings_past_events_hint))
options.forEachIndexed { index, mode ->
val isSelected = mode == selected
GroupedRow(
title = stringResource(pastEventDisplayLabelRes(mode)),
position = positionOf(index, options.size),
selected = isSelected,
trailing = if (isSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = { onSelect(mode) },
)
}
}
}
@@ -4,10 +4,12 @@ import android.content.Context
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
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
@@ -20,6 +22,8 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.locale.currentLocale
/**
@@ -60,6 +64,17 @@ internal fun CategoryIcon(icon: ImageVector, accent: ChipAccent) {
}
}
/** Muted supporting text under a [GroupedSectionHeader], matching the form-fields hint. */
@Composable
internal fun SettingsHint(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 4.dp),
)
}
internal fun openUrl(context: Context, url: String) {
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
runCatching { context.startActivity(intent) }
@@ -59,13 +59,14 @@ import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.LanguagePickerRow
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.crash.CrashReportDialog
import de.jeanlucmakiola.floret.crash.CrashReporter
import de.jeanlucmakiola.floret.crash.openIssueTracker
import de.jeanlucmakiola.floret.crash.submitCrashReport
import de.jeanlucmakiola.floret.identity.predictiveBackExit
import de.jeanlucmakiola.floret.locale.AppLanguage
/** The settings sub-screens reached from the hub's category rows. */
private enum class SettingsSection {
@@ -255,27 +256,7 @@ private fun SettingsHub(
leading = { CategoryIcon(Icons.Default.Widgets, ChipAccent.Tertiary) },
onClick = { onOpenSection(SettingsSection.Widgets) },
)
LanguagePickerRow(
position = Position.Middle,
title = stringResource(R.string.settings_language),
autoLabel = stringResource(R.string.settings_language_auto),
localesConfig = R.xml.locales_config,
predictiveBack = true,
// Invite contributions right where users pick their language.
header = {
val context = LocalContext.current
val translateUrl = stringResource(R.string.about_translate_url)
GroupedRow(
title = stringResource(R.string.settings_translate),
summary = stringResource(R.string.settings_translate_hint),
position = Position.Alone,
leading = { CategoryIcon(Icons.Default.Translate, ChipAccent.Neutral) },
onClick = { openUrl(context, translateUrl) },
)
Spacer(Modifier.height(16.dp))
},
leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Secondary) },
)
LanguageRow(position = Position.Middle)
ReportProblemRow(position = Position.Bottom)
// Source, licence and privacy sit at the bottom as reference material
@@ -347,6 +328,58 @@ private fun ReportProblemRow(position: Position) {
}
}
@Composable
private fun LanguageRow(position: Position) {
val context = LocalContext.current
val supported = remember { AppLanguage.supportedTags(context, R.xml.locales_config) }
// Setting a locale recreates the activity; mirror the choice locally so the
// row updates instantly even before the recreation lands.
var current by remember { mutableStateOf(AppLanguage.currentTag(supported)) }
var showDialog by remember { mutableStateOf(false) }
// null = follow the system; the rest are BCP-47 tags from locales_config.xml.
val options = remember(supported) { listOf<String?>(null) + supported }
GroupedRow(
title = stringResource(R.string.settings_language),
summary = languageLabel(current),
position = position,
leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Secondary) },
onClick = { showDialog = true },
)
if (showDialog) {
OptionPicker(
title = stringResource(R.string.settings_language),
predictiveBack = true,
options = options,
selected = current,
label = { languageLabel(it) },
onSelect = {
current = it
AppLanguage.apply(it)
},
onDismiss = { showDialog = false },
// Invite contributions right where users pick their language.
header = {
val translateUrl = stringResource(R.string.about_translate_url)
GroupedRow(
title = stringResource(R.string.settings_translate),
summary = stringResource(R.string.settings_translate_hint),
position = Position.Alone,
leading = { CategoryIcon(Icons.Default.Translate, ChipAccent.Neutral) },
onClick = { openUrl(context, translateUrl) },
)
Spacer(Modifier.height(16.dp))
},
)
}
}
@Composable
private fun languageLabel(tag: String?): String =
if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag)
@Composable
private fun AboutCard() {
// Layout lives in floret-kit (components.AboutCard); Calendula supplies
@@ -18,7 +18,6 @@ import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncEngine
import de.jeanlucmakiola.calendula.data.contacts.resolveTitleTemplate
import de.jeanlucmakiola.calendula.data.fonts.CustomFontStore
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
@@ -55,10 +54,12 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -229,8 +230,9 @@ class SettingsViewModel @Inject constructor(
defaultEventDurationMinutes = overrides.durations.default,
perCalendarEventDuration = overrides.durations.perCalendar,
)
}.stateWhileSubscribed(
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
)
@@ -246,8 +248,9 @@ class SettingsViewModel @Inject constructor(
prefs.plainFontStamp,
) { brand, plain, brandStamp, plainStamp ->
AppFontSettings(brand = brand, plain = plain, brandStamp = brandStamp, plainStamp = plainStamp)
}.stateWhileSubscribed(
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = AppFontSettings(),
)
@@ -370,8 +373,9 @@ class SettingsViewModel @Inject constructor(
allDayOverrides.reminderOverrideFor(calendarId)
},
)
}.stateWhileSubscribed(
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = SpecialDatesUiState(),
)
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.ui.settings
import android.Manifest
import android.content.Context
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
@@ -13,6 +14,7 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -32,12 +34,9 @@ import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSwitchRow
import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.locale.relativeTimeAgo
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.minutesOrEmpty
// ---------------------------------------------------------------------------
// Contact special dates (issue #15)
@@ -107,26 +106,38 @@ internal fun SpecialDatesScreen(
Spacer(Modifier.height(16.dp))
}
GroupedSwitchRow(
GroupedRow(
title = stringResource(R.string.settings_special_dates_enable),
checked = state.enabled,
onCheckedChange = { want -> if (want) requestOrEnable() else confirmDisableAll = true },
summary = stringResource(R.string.settings_special_dates_enable_hint),
position = Position.Alone,
trailing = {
Switch(
checked = state.enabled,
onCheckedChange = { want -> if (want) requestOrEnable() else confirmDisableAll = true },
)
},
onClick = { if (state.enabled) confirmDisableAll = true else requestOrEnable() },
)
if (state.enabled) {
SpecialDateType.entries.forEachIndexed { index, type ->
Spacer(Modifier.height(if (index == 0) 24.dp else 16.dp))
val on = type in state.types
GroupedSwitchRow(
GroupedRow(
title = stringResource(specialDateTypeLabel(type)),
checked = on,
onCheckedChange = { want ->
if (want) viewModel.setSpecialDateTypeEnabled(type, true)
else confirmDisableType = type
},
position = if (on) Position.Top else Position.Alone,
trailing = {
Switch(
checked = on,
onCheckedChange = { want ->
if (want) viewModel.setSpecialDateTypeEnabled(type, true)
else confirmDisableType = type
},
)
},
onClick = {
if (on) confirmDisableType = type else viewModel.setSpecialDateTypeEnabled(type, true)
},
)
if (on) {
GroupedRow(
@@ -137,7 +148,7 @@ internal fun SpecialDatesScreen(
)
GroupedRow(
title = stringResource(R.string.settings_special_dates_reminders),
summary = reminderChoiceLabel(state.reminderChoices[type]?.minutesOrEmpty().orEmpty()),
summary = reminderChoiceLabel(specialDatesReminderMinutes(state.reminderChoices[type])),
position = Position.Bottom,
onClick = { reminderPickerType = type },
)
@@ -145,16 +156,21 @@ internal fun SpecialDatesScreen(
}
Spacer(Modifier.height(24.dp))
GroupedSwitchRow(
GroupedRow(
title = stringResource(R.string.settings_special_dates_show_year),
checked = state.showYear,
onCheckedChange = viewModel::setSpecialDatesShowYear,
summary = stringResource(R.string.settings_special_dates_show_year_hint),
position = Position.Top,
trailing = {
Switch(
checked = state.showYear,
onCheckedChange = viewModel::setSpecialDatesShowYear,
)
},
onClick = { viewModel.setSpecialDatesShowYear(!state.showYear) },
)
GroupedRow(
title = stringResource(R.string.settings_special_dates_sync_now),
summary = specialDatesLastRunLabel(state.lastRun),
summary = specialDatesLastRunLabel(context, state.lastRun),
position = Position.Bottom,
onClick = viewModel::syncSpecialDatesNow,
)
@@ -207,6 +223,10 @@ internal fun SpecialDatesScreen(
}
}
/** The lead-time list backing a managed calendar's reminder choice (for the summary label). */
private fun specialDatesReminderMinutes(choice: ReminderOverride?): List<Int> =
(choice as? ReminderOverride.Minutes)?.minutes.orEmpty()
@Composable
private fun SpecialDatesDisableDialog(
message: String,
@@ -285,12 +305,16 @@ private fun specialDateTypeLabel(type: SpecialDateType): Int = when (type) {
}
@Composable
private fun specialDatesLastRunLabel(lastRun: Long): String =
private fun specialDatesLastRunLabel(context: Context, lastRun: Long): String =
if (lastRun <= 0L) {
stringResource(R.string.settings_special_dates_never_synced)
} else {
stringResource(
R.string.settings_special_dates_last_synced,
relativeTimeAgo(lastRun),
android.text.format.DateUtils.getRelativeTimeSpanString(
lastRun,
System.currentTimeMillis(),
android.text.format.DateUtils.MINUTE_IN_MILLIS,
).toString(),
)
}
@@ -33,6 +33,7 @@ import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.TimelineHours
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
@@ -45,10 +46,8 @@ import de.jeanlucmakiola.calendula.ui.common.label
import de.jeanlucmakiola.calendula.ui.common.labelRes
import de.jeanlucmakiola.calendula.ui.month.labelRes
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.GroupedSwitchRow
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.ReorderableColumn
@@ -113,19 +112,29 @@ internal fun ViewsScreen(
position = Position.Middle,
onClick = { showTimeFormat = true },
)
GroupedSwitchRow(
GroupedRow(
title = stringResource(R.string.settings_today_toolbar),
checked = state.todayButtonInToolbar,
onCheckedChange = viewModel::setTodayButtonInToolbar,
summary = stringResource(R.string.settings_today_toolbar_summary),
position = Position.Middle,
trailing = {
Switch(
checked = state.todayButtonInToolbar,
onCheckedChange = viewModel::setTodayButtonInToolbar,
)
},
onClick = { viewModel.setTodayButtonInToolbar(!state.todayButtonInToolbar) },
)
GroupedSwitchRow(
GroupedRow(
title = stringResource(R.string.settings_dim_completed),
checked = state.dimCompletedEvents,
onCheckedChange = viewModel::setDimCompletedEvents,
summary = stringResource(R.string.settings_dim_completed_summary),
position = Position.Bottom,
trailing = {
Switch(
checked = state.dimCompletedEvents,
onCheckedChange = viewModel::setDimCompletedEvents,
)
},
onClick = { viewModel.setDimCompletedEvents(!state.dimCompletedEvents) },
)
Spacer(Modifier.height(8.dp))
@@ -138,12 +147,17 @@ internal fun ViewsScreen(
position = Position.Top,
onClick = { showTapOpens = true },
)
GroupedSwitchRow(
GroupedRow(
title = stringResource(R.string.settings_drag_to_reschedule),
checked = state.dragToReschedule,
onCheckedChange = viewModel::setDragToReschedule,
summary = stringResource(R.string.settings_drag_to_reschedule_summary),
position = Position.Bottom,
trailing = {
Switch(
checked = state.dragToReschedule,
onCheckedChange = viewModel::setDragToReschedule,
)
},
onClick = { viewModel.setDragToReschedule(!state.dragToReschedule) },
)
Spacer(Modifier.height(8.dp))
@@ -154,12 +168,17 @@ internal fun ViewsScreen(
position = Position.Top,
onClick = { showMonthStyle = true },
)
GroupedSwitchRow(
GroupedRow(
title = stringResource(R.string.settings_week_numbers),
checked = state.showWeekNumbers,
onCheckedChange = viewModel::setShowWeekNumbers,
summary = stringResource(R.string.settings_week_numbers_summary),
position = Position.Bottom,
trailing = {
Switch(
checked = state.showWeekNumbers,
onCheckedChange = viewModel::setShowWeekNumbers,
)
},
onClick = { viewModel.setShowWeekNumbers(!state.showWeekNumbers) },
)
Spacer(Modifier.height(8.dp))
@@ -181,10 +200,8 @@ internal fun ViewsScreen(
dimmed = hours.enabled,
onClick = if (hours.enabled) null else ({ showTimelineScale = true }),
)
GroupedSwitchRow(
GroupedRow(
title = stringResource(R.string.settings_visible_hours),
checked = hours.enabled,
onCheckedChange = { viewModel.setTimelineHours(hours.copy(enabled = it)) },
summary = if (hours.enabled) {
stringResource(
R.string.settings_visible_hours_on_summary,
@@ -195,6 +212,13 @@ internal fun ViewsScreen(
stringResource(R.string.settings_visible_hours_summary)
},
position = Position.Middle,
trailing = {
Switch(
checked = hours.enabled,
onCheckedChange = { viewModel.setTimelineHours(hours.copy(enabled = it)) },
)
},
onClick = { viewModel.setTimelineHours(hours.copy(enabled = !hours.enabled)) },
)
if (hours.enabled) {
GroupedRow(
@@ -210,12 +234,17 @@ internal fun ViewsScreen(
onClick = { showHoursEnd = true },
)
}
GroupedSwitchRow(
GroupedRow(
title = stringResource(R.string.settings_hour_lines),
checked = state.showHourGrid,
onCheckedChange = viewModel::setShowHourGrid,
summary = stringResource(R.string.settings_hour_lines_summary),
position = Position.Bottom,
trailing = {
Switch(
checked = state.showHourGrid,
onCheckedChange = viewModel::setShowHourGrid,
)
},
onClick = { viewModel.setShowHourGrid(!state.showHourGrid) },
)
Spacer(Modifier.height(8.dp))
@@ -232,24 +261,34 @@ internal fun ViewsScreen(
position = Position.Middle,
onClick = { showPastEvents = true },
)
GroupedSwitchRow(
GroupedRow(
title = stringResource(R.string.settings_agenda_show_today),
checked = state.agendaShowToday,
onCheckedChange = viewModel::setAgendaShowToday,
summary = stringResource(R.string.settings_agenda_show_today_hint),
position = Position.Middle,
trailing = {
Switch(
checked = state.agendaShowToday,
onCheckedChange = viewModel::setAgendaShowToday,
)
},
onClick = { viewModel.setAgendaShowToday(!state.agendaShowToday) },
)
GroupedSwitchRow(
GroupedRow(
title = stringResource(R.string.settings_agenda_range_bar),
checked = state.agendaShowRangeBar,
onCheckedChange = viewModel::setAgendaShowRangeBar,
summary = stringResource(R.string.settings_agenda_range_bar_hint),
position = Position.Bottom,
trailing = {
Switch(
checked = state.agendaShowRangeBar,
onCheckedChange = viewModel::setAgendaShowRangeBar,
)
},
onClick = { viewModel.setAgendaShowRangeBar(!state.agendaShowRangeBar) },
)
Spacer(Modifier.height(24.dp))
GroupedSectionHeader(stringResource(R.string.settings_quick_switch_header))
GroupedHint(stringResource(R.string.settings_quick_switch_hint))
SettingsHint(stringResource(R.string.settings_quick_switch_hint))
Spacer(Modifier.height(8.dp))
ReorderableColumn(
items = config.order,
@@ -274,7 +313,7 @@ internal fun ViewsScreen(
Spacer(Modifier.height(24.dp))
GroupedSectionHeader(stringResource(R.string.settings_drawer_order_header))
GroupedHint(stringResource(R.string.settings_drawer_order_hint))
SettingsHint(stringResource(R.string.settings_drawer_order_hint))
Spacer(Modifier.height(8.dp))
ReorderableColumn(
items = state.drawerViewOrder,
@@ -302,7 +341,7 @@ internal fun ViewsScreen(
if (showDefaultView) {
OptionPicker(
title = stringResource(R.string.settings_default_view),
header = { GroupedHint(stringResource(R.string.settings_default_view_hint), verticalPadding = 8.dp) },
header = { PickerDescription(stringResource(R.string.settings_default_view_hint)) },
predictiveBack = true,
options = IMPLEMENTED_VIEWS,
selected = state.defaultView,
@@ -333,7 +372,7 @@ internal fun ViewsScreen(
val systemIs24Hour = DateFormat.is24HourFormat(LocalContext.current)
OptionPicker(
title = stringResource(R.string.settings_time_format),
header = { GroupedHint(stringResource(R.string.settings_time_format_hint), verticalPadding = 8.dp) },
header = { PickerDescription(stringResource(R.string.settings_time_format_hint)) },
predictiveBack = true,
options = TimeFormatPref.entries,
selected = state.timeFormat,
@@ -364,7 +403,7 @@ internal fun ViewsScreen(
if (showTapOpens) {
OptionPicker(
title = stringResource(R.string.settings_tap_opens),
header = { GroupedHint(stringResource(R.string.settings_tap_opens_hint), verticalPadding = 8.dp) },
header = { PickerDescription(stringResource(R.string.settings_tap_opens_hint)) },
predictiveBack = true,
options = listOf(false, true),
selected = state.tapOpensEditor,
@@ -383,7 +422,7 @@ internal fun ViewsScreen(
if (showMultiDayCount) {
OptionPicker(
title = stringResource(R.string.settings_multi_day_count),
header = { GroupedHint(stringResource(R.string.settings_multi_day_count_hint), verticalPadding = 8.dp) },
header = { PickerDescription(stringResource(R.string.settings_multi_day_count_hint)) },
predictiveBack = true,
options = SettingsPrefs.MULTI_DAY_COUNTS.toList(),
selected = state.multiDayCount,
@@ -395,7 +434,7 @@ internal fun ViewsScreen(
if (showTimelineScale) {
OptionPicker(
title = stringResource(R.string.settings_timeline_scale),
header = { GroupedHint(stringResource(R.string.settings_timeline_scale_hint), verticalPadding = 8.dp) },
header = { PickerDescription(stringResource(R.string.settings_timeline_scale_hint)) },
predictiveBack = true,
// A height pinched on the timeline is listed alongside the presets
// rather than left as a silently unticked list: it is the current
@@ -414,7 +453,7 @@ internal fun ViewsScreen(
if (showHoursStart) {
OptionPicker(
title = stringResource(R.string.settings_visible_hours_start),
header = { GroupedHint(stringResource(R.string.settings_visible_hours_start_hint), verticalPadding = 8.dp) },
header = { PickerDescription(stringResource(R.string.settings_visible_hours_start_hint)) },
predictiveBack = true,
options = TimelineHours.startOptions(hours.endHour),
selected = hours.startHour,
@@ -426,7 +465,7 @@ internal fun ViewsScreen(
if (showHoursEnd) {
OptionPicker(
title = stringResource(R.string.settings_visible_hours_end),
header = { GroupedHint(stringResource(R.string.settings_visible_hours_end_hint), verticalPadding = 8.dp) },
header = { PickerDescription(stringResource(R.string.settings_visible_hours_end_hint)) },
predictiveBack = true,
options = TimelineHours.endOptions(hours.startHour),
selected = hours.endHour,
@@ -8,12 +8,14 @@ import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.OptionRows
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.ViewPreviewFrame
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.locale.currentLocale
/**
@@ -49,23 +51,29 @@ internal fun WeekStartPicker(
height = PREVIEW_HEIGHT,
)
}
GroupedHint(stringResource(R.string.settings_week_start_hint), verticalPadding = 8.dp)
OptionRows(
options = options,
selected = selected,
label = { weekStartLabel(it) },
onSelect = onSelect,
summary = { option ->
if (option == WeekStartPref.Auto) {
PickerDescription(stringResource(R.string.settings_week_start_hint))
options.forEachIndexed { index, option ->
val isSelected = option == selected
GroupedRow(
title = weekStartLabel(option),
summary = if (option == WeekStartPref.Auto) {
stringResource(
R.string.settings_week_start_auto_summary,
weekStartLabel(WeekStartPref.Day(option.resolveFirstDay(locale))),
)
} else {
null
}
},
)
},
position = positionOf(index, options.size),
selected = isSelected,
trailing = if (isSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = { onSelect(option) },
)
}
}
}
@@ -21,11 +21,11 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.qs.NewEventTileService
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.calendula.widget.WidgetSize
import de.jeanlucmakiola.calendula.widget.agenda.metricsFor
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
@@ -53,7 +53,7 @@ internal fun WidgetsScreen(
predictiveBack = true,
visible = visible,
) {
GroupedHint(stringResource(R.string.settings_widgets_hint))
SettingsHint(stringResource(R.string.settings_widgets_hint))
Spacer(Modifier.height(8.dp))
GroupedRow(
@@ -90,7 +90,7 @@ internal fun WidgetsScreen(
if (showWidgetSize) {
OptionPicker(
title = stringResource(R.string.settings_widget_size),
header = { GroupedHint(stringResource(R.string.settings_widget_size_hint), verticalPadding = 8.dp) },
header = { PickerDescription(stringResource(R.string.settings_widget_size_hint)) },
predictiveBack = true,
options = WidgetSize.entries,
selected = state.widgetSize,
@@ -15,7 +15,6 @@ import de.jeanlucmakiola.calendula.domain.displayPriority
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -98,15 +97,17 @@ abstract class PagedTimelineViewModel(
/** Whether to fade events that have already finished (display concern only). */
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
.stateWhileSubscribed(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false,
)
/** How long a block drafted on an empty slot starts out (#153). */
val defaultEventDuration: StateFlow<Int> = settingsPrefs.defaultEventDurationMinutes
.stateWhileSubscribed(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = SettingsPrefs.DEFAULT_EVENT_DURATION,
)
@@ -134,13 +135,14 @@ abstract class PagedTimelineViewModel(
lastToday = date
if (old != date) followToday(old, date)
}
.stateWhileSubscribed(viewModelScope, todayDate)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), todayDate)
private val position: StateFlow<PagerPosition> =
combine(_anchor, rule) { anchor, rule -> positionOf(anchor, rule) }
.distinctUntilChanged()
.stateWhileSubscribed(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = positionOf(todayDate, initialRule),
)
@@ -154,8 +156,9 @@ abstract class PagedTimelineViewModel(
.distinctUntilChanged()
.flatMapLatest { span -> span(span).filterNot { it is WeekUiState.Loading } }
.catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) }
.stateWhileSubscribed(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = WeekUiState.Loading,
)