Move onto floret's shared rows, onboarding pieces and Android helpers (#410)
Stacked on #409 (its commit shows here until it merges). Bumps floret-kit to 5093e1c (floret main, floret-kit#14) and deletes the local copies of what moved there. Widget code untouched. - `GroupedRow(checked)` for the check-mark rows; `GroupedSwitchRow` for 16 switch rows; `OptionRows` in the three settings preview pickers; `CustomExpandRow` for the three "Custom" picker rows - `IconInfoCard` replaces `EditCard` and `DetailCard`; `GroupedHint` replaces `SettingsHint` / `HintText` / `PickerDescription` - Onboarding steps use `OnboardingScaffold(headline, supporting)` and `OnboardingPrimaryButton` - `LanguagePickerRow(header, predictiveBack)` replaces the local `LanguageRow` - `relativeTimeAgo`, `ReminderOverride.minutesOrEmpty()` - New `core-android` dependency: `goAsync { }` in the reminder receivers, `hasPermission` for all non-widget permission checks, `broadcastTicks` behind TodaySource / ZoneSource, `setWakeup` in the reminder and snooze alarms, `stateWhileSubscribed` for the 39 `WhileSubscribed(5_000)` sites One visible change: the expand rows in Event form and Notification settings show floret's rotating chevron instead of swapping ExpandLess/ExpandMore. Kept local on purpose: the Reliable delivery row (no selected highlight today), the Dynamic colour switch (isn't dimmed when unavailable), HiddenCalendarsStep's headline (needs its inset padding) and ViewStep's onboarding block (headlineSmall). Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de> Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/410
This commit is contained in:
@@ -5,12 +5,10 @@ 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
|
||||
@@ -28,6 +26,7 @@ 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
|
||||
@@ -133,8 +132,7 @@ class BackupWorker(
|
||||
|
||||
private fun notifyFailure(context: Context) {
|
||||
val canPost = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
context.hasPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
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,7 +14,6 @@ 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
|
||||
@@ -326,8 +325,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
private val registeredObservers = mutableSetOf<ContentObserver>()
|
||||
|
||||
private fun hasCalendarPermission(): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
context.hasPermission(Manifest.permission.READ_CALENDAR)
|
||||
|
||||
/**
|
||||
* Attach any not-yet-registered observers to the provider, but only once the
|
||||
@@ -463,8 +461,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
}
|
||||
|
||||
override fun canWriteCalendars(): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
context.hasPermission(Manifest.permission.WRITE_CALENDAR)
|
||||
|
||||
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
|
||||
val name = displayName.trim()
|
||||
|
||||
+1
-1
@@ -1,8 +1,8 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.util.Log
|
||||
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||
import de.jeanlucmakiola.floret.time.toEpochMillis
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
|
||||
+4
-7
@@ -4,15 +4,15 @@ 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
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
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
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -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 (!hasPermission(Manifest.permission.READ_CALENDAR)) return@withContext
|
||||
if (!context.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() || !hasPermission(Manifest.permission.WRITE_CALENDAR)) {
|
||||
if (pending.isEmpty() || !context.hasPermission(Manifest.permission.WRITE_CALENDAR)) {
|
||||
return@withContext
|
||||
}
|
||||
val plan = calendarVisibilityPlan(calendars, pending)
|
||||
@@ -97,9 +97,6 @@ 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
-4
@@ -2,16 +2,15 @@ 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
|
||||
|
||||
@@ -31,8 +30,7 @@ interface ContactSpecialDatesDataSource {
|
||||
|
||||
/** Whether `READ_CONTACTS` is granted — the gate for every contacts read. */
|
||||
fun Context.hasContactsPermission(): Boolean =
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
hasPermission(Manifest.permission.READ_CONTACTS)
|
||||
|
||||
/** Map a `ContactsContract` event `TYPE` to our calendar bucket. */
|
||||
internal fun specialDateTypeForRawEventType(type: Int): SpecialDateType = when (type) {
|
||||
|
||||
@@ -24,8 +24,6 @@ import de.jeanlucmakiola.calendula.data.time.AndroidTodaySource
|
||||
import de.jeanlucmakiola.calendula.data.time.AndroidZoneSource
|
||||
import de.jeanlucmakiola.calendula.data.time.TodaySource
|
||||
import de.jeanlucmakiola.calendula.data.time.ZoneSource
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import javax.inject.Singleton
|
||||
|
||||
private val Context.calendulaDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
@@ -87,8 +85,4 @@ object DataProvideModule {
|
||||
@Singleton
|
||||
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||
context.calendulaDataStore
|
||||
|
||||
@Provides
|
||||
@IoDispatcher
|
||||
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.data.di
|
||||
|
||||
import javax.inject.Qualifier
|
||||
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class IoDispatcher
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
@@ -30,10 +31,8 @@ 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
|
||||
@@ -104,9 +103,8 @@ fun WeekStartPref.resolveFirstDay(locale: Locale): DayOfWeek = when (this) {
|
||||
fun SettingsPrefs.firstDayOfWeek(scope: CoroutineScope): StateFlow<DayOfWeek> =
|
||||
weekStart
|
||||
.map { it.resolveFirstDay(Locale.getDefault()) }
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
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()),
|
||||
|
||||
+8
-21
@@ -6,11 +6,8 @@ import android.content.Intent
|
||||
import androidx.core.net.toUri
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import de.jeanlucmakiola.floret.android.goAsync
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
@@ -44,27 +41,17 @@ 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)
|
||||
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()
|
||||
}
|
||||
goAsync {
|
||||
val minutes = settingsPrefs.snoozeMinutes.first()
|
||||
val triggerAt = System.currentTimeMillis() + minutes * 60_000L
|
||||
scheduler.schedule(alert, triggerAt)
|
||||
}
|
||||
}
|
||||
|
||||
ACTION_SHOW -> {
|
||||
val pendingResult = goAsync()
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
if (settingsPrefs.remindersEnabled.first() && notifier.canPost()) {
|
||||
notifier.post(alert)
|
||||
}
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
goAsync {
|
||||
if (settingsPrefs.remindersEnabled.first() && notifier.canPost()) {
|
||||
notifier.post(alert)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,20 +4,12 @@ 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
|
||||
@@ -31,16 +23,7 @@ class ReminderAlarmScheduler @Inject constructor(
|
||||
|
||||
fun scheduleScan(triggerAtMillis: Long) {
|
||||
val alarmManager = context.getSystemService<AlarmManager>() ?: return
|
||||
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,
|
||||
)
|
||||
}
|
||||
alarmManager.setWakeup(triggerAtMillis, scanPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
}
|
||||
|
||||
/** Drop the pending wake-up — reminders are off, or there is nothing to wait for. */
|
||||
|
||||
@@ -5,12 +5,10 @@ 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
|
||||
@@ -19,6 +17,7 @@ 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
|
||||
@@ -48,8 +47,7 @@ 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 ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
context.hasPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||
}
|
||||
|
||||
|
||||
@@ -2,19 +2,18 @@ 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
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ReminderStatePrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
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
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -113,9 +112,7 @@ class ReminderScanner @Inject constructor(
|
||||
alarms.scheduleScan(schedule.nextAlarmMillis)
|
||||
}
|
||||
|
||||
private fun hasReadCalendar(): Boolean = ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.READ_CALENDAR,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
private fun hasReadCalendar(): Boolean = context.hasPermission(Manifest.permission.READ_CALENDAR)
|
||||
|
||||
/**
|
||||
* Re-scan when the provider changes, so a saved or deleted event re-arms the
|
||||
|
||||
+2
-12
@@ -4,10 +4,7 @@ import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import de.jeanlucmakiola.floret.android.goAsync
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
@@ -29,14 +26,7 @@ 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
|
||||
val pendingResult = goAsync()
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
scanner.scan()
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
goAsync { scanner.scan() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
+2
-20
@@ -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,24 +31,6 @@ class ReminderSnoozeScheduler @Inject constructor(
|
||||
ReminderActionReceiver.intent(context, ReminderActionReceiver.ACTION_SHOW, alert),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
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,
|
||||
)
|
||||
}
|
||||
alarmManager.setWakeup(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,18 +1,13 @@
|
||||
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
|
||||
@@ -34,21 +29,8 @@ class AndroidTodaySource @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : TodaySource {
|
||||
|
||||
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) }
|
||||
override val today: Flow<LocalDate> = context
|
||||
.broadcastTicks(Intent.ACTION_DATE_CHANGED, Intent.ACTION_TIME_CHANGED, Intent.ACTION_TIMEZONE_CHANGED)
|
||||
.transformLatest {
|
||||
while (true) {
|
||||
emit(currentDate())
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
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 kotlinx.coroutines.channels.awaitClose
|
||||
import de.jeanlucmakiola.floret.android.broadcastTicks
|
||||
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
|
||||
|
||||
@@ -33,17 +28,7 @@ class AndroidZoneSource @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : ZoneSource {
|
||||
|
||||
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) }
|
||||
override val zone: Flow<TimeZone> = context.broadcastTicks(Intent.ACTION_TIMEZONE_CHANGED)
|
||||
.map { current() }
|
||||
.distinctUntilChanged()
|
||||
|
||||
|
||||
@@ -8,11 +8,10 @@ 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 kotlinx.coroutines.flow.SharingStarted
|
||||
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
||||
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
|
||||
|
||||
@@ -29,40 +28,35 @@ class CalendarHostViewModel @Inject constructor(
|
||||
) : ViewModel() {
|
||||
|
||||
/** Today, kept current across midnight so the shared focus can follow it (#373). */
|
||||
val today: StateFlow<LocalDate> = todaySource.today.stateIn(
|
||||
val today: StateFlow<LocalDate> = todaySource.today.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = currentDate(),
|
||||
)
|
||||
|
||||
val defaultView: StateFlow<CalendarView?> = prefs.defaultView.stateIn(
|
||||
val defaultView: StateFlow<CalendarView?> = prefs.defaultView.stateWhileSubscribed(
|
||||
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 }
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
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
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
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
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
@@ -74,9 +68,8 @@ class CalendarHostViewModel @Inject constructor(
|
||||
* switched off to be rid of.
|
||||
*/
|
||||
val dragToReschedule: StateFlow<Boolean> = prefs.dragToReschedule
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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
|
||||
@@ -20,7 +19,6 @@ 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
|
||||
@@ -31,6 +29,7 @@ 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. */
|
||||
@@ -61,8 +60,7 @@ fun RootScreen(
|
||||
val context = LocalContext.current
|
||||
var hasPermission by remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR)
|
||||
== PackageManager.PERMISSION_GRANTED
|
||||
context.hasPermission(Manifest.permission.READ_CALENDAR)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -74,9 +72,7 @@ fun RootScreen(
|
||||
DisposableEffect(lifecycle) {
|
||||
val obs = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
hasPermission = ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.READ_CALENDAR
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
hasPermission = context.hasPermission(Manifest.permission.READ_CALENDAR)
|
||||
// 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.
|
||||
|
||||
@@ -4,7 +4,6 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
||||
@@ -14,6 +13,8 @@ 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
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -66,9 +67,8 @@ 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
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = PastEventDisplay.SHOW,
|
||||
)
|
||||
|
||||
@@ -79,9 +79,8 @@ class AgendaViewModel @Inject constructor(
|
||||
* alongside the data state like [pastEventDisplay] rather than re-querying.
|
||||
*/
|
||||
val showToday: StateFlow<Boolean> = settingsPrefs.agendaShowToday
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = true,
|
||||
)
|
||||
|
||||
@@ -115,7 +114,7 @@ class AgendaViewModel @Inject constructor(
|
||||
_window.value = _window.value.copy(anchor = date)
|
||||
}
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), todayDate)
|
||||
.stateWhileSubscribed(viewModelScope, 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
|
||||
@@ -156,9 +155,8 @@ class AgendaViewModel @Inject constructor(
|
||||
}
|
||||
.catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = AgendaUiState.Loading,
|
||||
)
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
|
||||
import de.jeanlucmakiola.calendula.ui.common.sampleAgendaEvents
|
||||
import de.jeanlucmakiola.floret.components.ScaledViewPreview
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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
|
||||
@@ -58,9 +57,12 @@ 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
|
||||
@@ -222,8 +224,8 @@ private fun BackupContent(
|
||||
val onRestoreClick = if (canImport) onRestore else (manageCalendars ?: onRestore)
|
||||
|
||||
if (exportable.isEmpty()) {
|
||||
SectionHeader(stringResource(R.string.calendars_restore_header))
|
||||
HintText(restoreSummary)
|
||||
GroupedSectionHeader(stringResource(R.string.calendars_restore_header))
|
||||
GroupedHint(restoreSummary, style = MaterialTheme.typography.bodySmall)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_restore_action),
|
||||
position = Position.Alone,
|
||||
@@ -233,7 +235,7 @@ private fun BackupContent(
|
||||
return
|
||||
}
|
||||
|
||||
HintText(stringResource(R.string.calendars_backup_hint))
|
||||
GroupedHint(stringResource(R.string.calendars_backup_hint), style = MaterialTheme.typography.bodySmall)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_backup_action),
|
||||
position = Position.Top,
|
||||
@@ -272,7 +274,7 @@ private fun BackupContent(
|
||||
position = Position.Bottom,
|
||||
onClick = onEditInterval,
|
||||
)
|
||||
HintText(backupStatusText(autoBackup.status))
|
||||
GroupedHint(backupStatusText(autoBackup.status), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,7 +313,7 @@ private fun ExportCalendarPicker(
|
||||
onDismiss = onDismiss,
|
||||
predictiveBack = true,
|
||||
) {
|
||||
HintText(stringResource(R.string.calendars_export_hint))
|
||||
GroupedHint(stringResource(R.string.calendars_export_hint), style = MaterialTheme.typography.bodySmall)
|
||||
calendars.forEachIndexed { index, calendar ->
|
||||
val isSelected = calendar.id in selected
|
||||
GroupedRow(
|
||||
@@ -377,9 +379,7 @@ 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 = DateUtils.getRelativeTimeSpanString(
|
||||
status.lastRun, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS,
|
||||
).toString()
|
||||
val relative = relativeTimeAgo(status.lastRun)
|
||||
return if (status.lastSuccess) {
|
||||
stringResource(R.string.calendars_auto_backup_status_ok, relative)
|
||||
} else {
|
||||
|
||||
@@ -83,11 +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
|
||||
@@ -213,7 +214,7 @@ private fun CalendarsList(
|
||||
visible = visible,
|
||||
) {
|
||||
// What the per-calendar / per-account switches below actually do.
|
||||
HintText(stringResource(R.string.calendars_visibility_hint))
|
||||
GroupedHint(stringResource(R.string.calendars_visibility_hint), style = MaterialTheme.typography.bodySmall)
|
||||
|
||||
// Local (device-only) calendars — one collapsible group. The header's
|
||||
// "+" adds a calendar; the switch enables/disables them all at once;
|
||||
@@ -234,7 +235,7 @@ private fun CalendarsList(
|
||||
onToggleAll = { enabled -> onSetAccountVisible(local.map { it.id }, enabled) },
|
||||
) {
|
||||
if (local.isEmpty()) {
|
||||
HintText(stringResource(R.string.calendars_local_empty))
|
||||
GroupedHint(stringResource(R.string.calendars_local_empty), style = MaterialTheme.typography.bodySmall)
|
||||
} else {
|
||||
local.forEachIndexed { index, calendar ->
|
||||
val disabled = !calendar.isVisibleInSystem
|
||||
@@ -280,8 +281,8 @@ private fun CalendarsList(
|
||||
// Synced calendars — read-only, grouped by account. Each account is a
|
||||
// collapsible group whose header opens the source app (icon) and toggles
|
||||
// all of its calendars at once (switch).
|
||||
SectionHeader(stringResource(R.string.calendars_synced_header))
|
||||
HintText(stringResource(R.string.calendars_synced_hint))
|
||||
GroupedSectionHeader(stringResource(R.string.calendars_synced_header))
|
||||
GroupedHint(stringResource(R.string.calendars_synced_hint), style = MaterialTheme.typography.bodySmall)
|
||||
synced
|
||||
.groupByAccount()
|
||||
.forEach { group ->
|
||||
@@ -830,33 +831,6 @@ private fun CalendarGroupMenu(
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
internal fun SectionHeader(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
// The cards' own edge, so header and group share a left margin.
|
||||
modifier = Modifier.padding(
|
||||
start = GroupedListInset,
|
||||
end = GroupedListInset,
|
||||
top = 16.dp,
|
||||
bottom = 4.dp,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@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
|
||||
* own authenticator package (resolved from [AccountManager], no permission
|
||||
|
||||
@@ -9,16 +9,16 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||
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,7 +26,6 @@ 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
|
||||
@@ -52,9 +51,8 @@ class CalendarsViewModel @Inject constructor(
|
||||
repository.calendars()
|
||||
.catch { emit(emptyList()) }
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = emptyList(),
|
||||
)
|
||||
|
||||
@@ -68,9 +66,8 @@ class CalendarsViewModel @Inject constructor(
|
||||
.map { backupUiState(it) }
|
||||
.catch { emit(BackupUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = BackupUiState.Loading,
|
||||
)
|
||||
|
||||
@@ -84,9 +81,8 @@ class CalendarsViewModel @Inject constructor(
|
||||
AutoBackupUiState(enabled, interval, folder, status)
|
||||
}
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = AutoBackupUiState(),
|
||||
)
|
||||
|
||||
@@ -103,9 +99,8 @@ class CalendarsViewModel @Inject constructor(
|
||||
if (!enabled) emptySet() else sources.filter { it.isManaged }.map { it.id }.toSet()
|
||||
}
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = emptySet(),
|
||||
)
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@ 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
|
||||
@@ -114,17 +113,11 @@ 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,
|
||||
selected = isSelected,
|
||||
checked = calendar.id == selectedId,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
trailing = if (isSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { onSelect(calendar.id) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ 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
|
||||
@@ -23,7 +22,6 @@ 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
|
||||
@@ -32,6 +30,7 @@ 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
|
||||
@@ -75,8 +74,7 @@ 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 =
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_CALENDAR) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
hasPermission(Manifest.permission.WRITE_CALENDAR)
|
||||
|
||||
/** [deleteEvent], with the provider's failures mapped to a [DeleteOutcome]. */
|
||||
suspend fun CalendarRepository.tryDeleteEvent(
|
||||
|
||||
@@ -6,7 +6,6 @@ import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
@@ -15,16 +14,16 @@ 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
|
||||
@@ -50,9 +49,8 @@ class EventActionsViewModel @Inject constructor(
|
||||
.map { list -> list.associateBy { it.id } }
|
||||
.catch { emit(emptyMap()) }
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
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).stateIn(scope + job, SharingStarted.WhileSubscribed(5_000L), initial),
|
||||
state = load(key).stateWhileSubscribed(scope + job, initial),
|
||||
job = job,
|
||||
)
|
||||
}.state
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
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
|
||||
@@ -20,17 +16,16 @@ 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.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedHint
|
||||
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
|
||||
@@ -88,7 +83,7 @@ fun ReminderDefaultPicker(
|
||||
}
|
||||
val inherits = current is ReminderOverride.Inherit
|
||||
val isNone = current is ReminderOverride.None
|
||||
val selectedMinutes = (current as? ReminderOverride.Minutes)?.minutes.orEmpty()
|
||||
val selectedMinutes = current.minutesOrEmpty()
|
||||
// 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.
|
||||
@@ -119,24 +114,14 @@ fun ReminderDefaultPicker(
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.reminder_use_default),
|
||||
position = Position.Top,
|
||||
selected = inherits,
|
||||
trailing = if (inherits) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
checked = inherits,
|
||||
onClick = { apply(ReminderOverride.Inherit) },
|
||||
)
|
||||
}
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.reminder_none),
|
||||
position = if (allowInherit) Position.Bottom else Position.Alone,
|
||||
selected = isNone,
|
||||
trailing = if (isNone) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
checked = isNone,
|
||||
onClick = { apply(ReminderOverride.None) },
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
@@ -152,17 +137,11 @@ fun ReminderDefaultPicker(
|
||||
onClick = { toggle(minute) },
|
||||
)
|
||||
}
|
||||
// When expanded, the Custom row connects downward into the editor card
|
||||
// so the two read as one grouped container (the per-calendar pattern).
|
||||
GroupedRow(
|
||||
CustomExpandRow(
|
||||
title = stringResource(R.string.event_edit_reminder_custom),
|
||||
position = if (customExpanded) Position.Top else positionOf(rows.size, rowCount),
|
||||
onClick = { customExpanded = !customExpanded },
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = customExpanded,
|
||||
enter = expandEnter(),
|
||||
exit = collapseExit(),
|
||||
position = positionOf(rows.size, rowCount),
|
||||
expanded = customExpanded,
|
||||
onExpandedChange = { customExpanded = it },
|
||||
) {
|
||||
CustomReminderEditor(
|
||||
amountText = amountText,
|
||||
@@ -210,19 +189,6 @@ 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
|
||||
@@ -263,17 +229,11 @@ fun AgendaRangePicker(
|
||||
}
|
||||
|
||||
val rangeRow: @Composable (AgendaRange, Position) -> Unit = { option, position ->
|
||||
val isSelected = option == selected
|
||||
GroupedRow(
|
||||
title = agendaRangeLabel(option),
|
||||
summary = windowSummary(option),
|
||||
position = position,
|
||||
selected = isSelected,
|
||||
trailing = if (isSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
checked = option == selected,
|
||||
onClick = {
|
||||
onSelect(option)
|
||||
onDismiss()
|
||||
@@ -282,7 +242,7 @@ fun AgendaRangePicker(
|
||||
}
|
||||
|
||||
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||
PickerDescription(description)
|
||||
GroupedHint(description, verticalPadding = 8.dp)
|
||||
|
||||
// Calendar-aligned windows.
|
||||
calendarAligned.forEachIndexed { index, option ->
|
||||
@@ -295,7 +255,7 @@ fun AgendaRangePicker(
|
||||
rolling.forEachIndexed { index, option ->
|
||||
rangeRow(option, positionOf(index, rollingRowCount))
|
||||
}
|
||||
GroupedRow(
|
||||
CustomExpandRow(
|
||||
title = if (customSelected) {
|
||||
agendaRangeLabel(selected)
|
||||
} else {
|
||||
@@ -304,19 +264,10 @@ 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 = 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(),
|
||||
position = positionOf(rolling.size, rollingRowCount),
|
||||
expanded = customExpanded,
|
||||
onExpandedChange = { customExpanded = it },
|
||||
checked = customSelected,
|
||||
) {
|
||||
CustomDaysEditor(
|
||||
amountText = amountText,
|
||||
@@ -394,17 +345,12 @@ fun DurationPicker(
|
||||
}
|
||||
|
||||
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) {
|
||||
if (description != null) PickerDescription(description)
|
||||
if (description != null) GroupedHint(description, verticalPadding = 8.dp)
|
||||
if (inheritLabel != null) {
|
||||
GroupedRow(
|
||||
title = inheritLabel,
|
||||
position = Position.Alone,
|
||||
selected = inherits,
|
||||
trailing = if (inherits) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
checked = inherits,
|
||||
onClick = {
|
||||
onSelect(null)
|
||||
onDismiss()
|
||||
@@ -413,39 +359,22 @@ fun DurationPicker(
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
presets.forEachIndexed { index, minute ->
|
||||
val isSelected = minute == selected
|
||||
GroupedRow(
|
||||
title = label(minute),
|
||||
position = positionOf(index, rowCount),
|
||||
selected = isSelected,
|
||||
trailing = if (isSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
checked = minute == selected,
|
||||
onClick = {
|
||||
onSelect(minute)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
// 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(
|
||||
CustomExpandRow(
|
||||
title = custom?.let { label(it) } ?: stringResource(R.string.event_edit_reminder_custom),
|
||||
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(),
|
||||
position = positionOf(presets.size, rowCount),
|
||||
expanded = customExpanded,
|
||||
onExpandedChange = { customExpanded = it },
|
||||
checked = custom != null,
|
||||
) {
|
||||
CustomDurationEditor(
|
||||
amountText = amountText,
|
||||
|
||||
@@ -6,7 +6,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.time.ZoneSource
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
||||
@@ -23,15 +22,15 @@ 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
|
||||
@@ -188,9 +187,8 @@ class RescheduleViewModel @Inject constructor(
|
||||
.map { calendars -> calendars.filter { it.allowsEventMove }.map { it.id }.toSet() }
|
||||
.catch { emit(emptySet<Long>()) }
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = emptySet(),
|
||||
)
|
||||
|
||||
|
||||
@@ -38,11 +38,10 @@ import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
||||
import de.jeanlucmakiola.calendula.domain.timeZoneOptions
|
||||
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
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
|
||||
@@ -117,19 +116,14 @@ fun TimeZonePickerDialog(
|
||||
title = stringResource(R.string.event_edit_timezone_device),
|
||||
summary = deviceSummary,
|
||||
position = Position.Alone,
|
||||
selected = selected == null,
|
||||
checked = selected == null,
|
||||
leading = { Icon(Icons.Default.Public, contentDescription = null) },
|
||||
trailing = if (selected == null) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { choose(null) },
|
||||
)
|
||||
}
|
||||
if (recentZones.isNotEmpty()) {
|
||||
item(key = "recent_header") {
|
||||
SectionHeader(stringResource(R.string.event_edit_timezone_recent))
|
||||
GroupedSectionHeader(stringResource(R.string.event_edit_timezone_recent))
|
||||
}
|
||||
itemsIndexed(
|
||||
items = recentZones,
|
||||
@@ -144,7 +138,7 @@ fun TimeZonePickerDialog(
|
||||
}
|
||||
}
|
||||
item(key = "all_header") {
|
||||
SectionHeader(stringResource(R.string.event_edit_timezone_all))
|
||||
GroupedSectionHeader(stringResource(R.string.event_edit_timezone_all))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,23 +227,6 @@ private fun SearchField(
|
||||
}
|
||||
}
|
||||
|
||||
/** A small primary-coloured group label, matching the settings screens. */
|
||||
@Composable
|
||||
private fun SectionHeader(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(
|
||||
start = GroupedListInset,
|
||||
end = GroupedListInset,
|
||||
top = 16.dp,
|
||||
bottom = 4.dp,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun ZoneRow(
|
||||
zone: TimeZoneOption,
|
||||
@@ -261,12 +238,7 @@ private fun ZoneRow(
|
||||
title = zone.label,
|
||||
summary = zoneDescriptor(zone),
|
||||
position = position,
|
||||
selected = selected,
|
||||
trailing = if (selected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
checked = selected,
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* The viewport a view preview pretends to be: a phone's width by the height a
|
||||
* calendar view gets under the top bar. Scaling every preview from one fixed
|
||||
* height keeps them comparable — each shows the same slice of screen.
|
||||
*/
|
||||
internal val PREVIEW_VIEWPORT_HEIGHT = 440.dp
|
||||
|
||||
/**
|
||||
* Renders [content] at a full phone viewport and shrinks the finished layout
|
||||
* into a box [height] tall, for the settings and onboarding choosers. Callers
|
||||
* pass the *real* view composables, so a preview cannot drift from what it
|
||||
* depicts.
|
||||
*
|
||||
* The [layout] below ignores the incoming constraints so the view measures at a
|
||||
* phone viewport, and a [graphicsLayer] scale shrinks the result.
|
||||
* `Modifier.requiredSize` is the obvious alternative but *centres* content that
|
||||
* overflows its constraints, leaving only the bottom-right corner in the clip.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ScaledViewPreview(
|
||||
height: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||
val scale = height.value / PREVIEW_VIEWPORT_HEIGHT.value
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clipToBounds()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
// A preview is a picture, not a control: swallow touches before the
|
||||
// view's own clickables see them, and give screen readers one label
|
||||
// instead of six weeks of day cells.
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
awaitPointerEvent(PointerEventPass.Initial).changes
|
||||
.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.clearAndSetSemantics { }
|
||||
// Measure at a full phone viewport but report the scaled size, so the
|
||||
// node occupies exactly what it draws.
|
||||
.layout { measurable, _ ->
|
||||
val fullWidth = screenWidth.roundToPx()
|
||||
val fullHeight = PREVIEW_VIEWPORT_HEIGHT.roundToPx()
|
||||
val placeable = measurable.measure(Constraints.fixed(fullWidth, fullHeight))
|
||||
layout((fullWidth * scale).roundToInt(), (fullHeight * scale).roundToInt()) {
|
||||
placeable.place(0, 0)
|
||||
}
|
||||
}
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
transformOrigin = TransformOrigin(0f, 0f)
|
||||
},
|
||||
) {
|
||||
Column(Modifier.fillMaxSize()) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The stage every preview picker sets its preview on, cross-fading as [selected]
|
||||
* changes. A preview draws the app's own surface, so a tonal plate gives it the
|
||||
* edge it would otherwise lack on a chooser screen; the plate takes the width
|
||||
* and outer radius of the grouped rows underneath so the two read as one stack.
|
||||
*
|
||||
* Only vertical spacing is the caller's to pass — the horizontal inset is the
|
||||
* rows' own, and the alignment is the point.
|
||||
*/
|
||||
@Composable
|
||||
internal fun <T> ViewPreviewFrame(
|
||||
selected: T,
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable (T) -> Unit,
|
||||
) {
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = GroupedListInset)
|
||||
.clip(FRAME_SHAPE)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
|
||||
.padding(FRAME_INSET),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Crossfade(
|
||||
targetState = selected,
|
||||
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
|
||||
label = label,
|
||||
) { shown ->
|
||||
// Each preview renders the real view at phone size and scales it
|
||||
// down, so its own corners are square — the plate rounds them.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(PREVIEW_SHAPE)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.clipToBounds(),
|
||||
) {
|
||||
content(shown)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val FRAME_INSET = 8.dp
|
||||
|
||||
/** The grouped rows' own outer radius, so the stack shares one silhouette. */
|
||||
private val FRAME_SHAPE = RoundedCornerShape(22.dp)
|
||||
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)
|
||||
@@ -8,7 +8,6 @@ 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
|
||||
@@ -17,7 +16,6 @@ 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
|
||||
@@ -58,7 +56,6 @@ 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
|
||||
@@ -92,6 +89,7 @@ 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
|
||||
@@ -384,7 +382,7 @@ private fun EventDetailContent(
|
||||
|
||||
// "When" — date/all-day plus the time range.
|
||||
val (whenPrimary, whenSecondary) = rememberWhen(instance, TimeZone.currentSystemDefault(), locale)
|
||||
DetailCard(icon = Icons.Default.Schedule, iconContentDescription = null) {
|
||||
IconInfoCard(icon = Icons.Default.Schedule, iconContentDescription = null) {
|
||||
Text(text = whenPrimary, style = MaterialTheme.typography.titleMedium)
|
||||
if (whenSecondary != null) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
@@ -409,7 +407,7 @@ private fun EventDetailContent(
|
||||
}
|
||||
foreignZone?.let { zoneOption ->
|
||||
Spacer(Modifier.height(gap))
|
||||
DetailCard(
|
||||
IconInfoCard(
|
||||
icon = Icons.Default.Public,
|
||||
iconContentDescription = stringResource(R.string.event_detail_timezone),
|
||||
) {
|
||||
@@ -441,7 +439,7 @@ private fun EventDetailContent(
|
||||
// Calendar — icon tinted in the calendar colour conveys identity, so no
|
||||
// separate colour dot is needed.
|
||||
Spacer(Modifier.height(gap))
|
||||
DetailCard(
|
||||
IconInfoCard(
|
||||
icon = Icons.Default.CalendarMonth,
|
||||
iconTint = accent,
|
||||
iconContentDescription = stringResource(R.string.event_detail_calendar),
|
||||
@@ -459,7 +457,7 @@ private fun EventDetailContent(
|
||||
val context = LocalContext.current
|
||||
val locationLabel = stringResource(R.string.event_detail_location)
|
||||
Spacer(Modifier.height(gap))
|
||||
DetailCard(
|
||||
IconInfoCard(
|
||||
icon = Icons.Default.Place,
|
||||
iconContentDescription = locationLabel,
|
||||
) {
|
||||
@@ -486,7 +484,7 @@ private fun EventDetailContent(
|
||||
state.description?.let { description ->
|
||||
val descriptionLabel = stringResource(R.string.event_detail_description)
|
||||
Spacer(Modifier.height(gap))
|
||||
DetailCard(
|
||||
IconInfoCard(
|
||||
icon = Icons.AutoMirrored.Filled.Notes,
|
||||
iconContentDescription = descriptionLabel,
|
||||
// The gesture sits on the card so the icon and padding answer
|
||||
@@ -505,7 +503,7 @@ private fun EventDetailContent(
|
||||
// each attendee with their role and reply.
|
||||
detail.attendees.takeIf { it.isNotEmpty() }?.let { attendees ->
|
||||
Spacer(Modifier.height(gap))
|
||||
DetailCard(
|
||||
IconInfoCard(
|
||||
icon = Icons.Default.People,
|
||||
iconContentDescription = stringResource(R.string.event_detail_attendees),
|
||||
) {
|
||||
@@ -527,7 +525,7 @@ private fun EventDetailContent(
|
||||
// Reminders (conditional) — list each lead time before the event.
|
||||
detail.reminders.takeIf { it.isNotEmpty() }?.let { reminders ->
|
||||
Spacer(Modifier.height(gap))
|
||||
DetailCard(
|
||||
IconInfoCard(
|
||||
icon = Icons.Default.Notifications,
|
||||
iconContentDescription = stringResource(R.string.event_detail_reminders),
|
||||
) {
|
||||
@@ -547,7 +545,7 @@ private fun EventDetailContent(
|
||||
// Recurrence (conditional) — humanised from the RRULE.
|
||||
detail.rrule?.takeIf { it.isNotBlank() }?.let { rrule ->
|
||||
Spacer(Modifier.height(gap))
|
||||
DetailCard(
|
||||
IconInfoCard(
|
||||
icon = Icons.Default.Repeat,
|
||||
iconContentDescription = stringResource(R.string.event_detail_recurrence),
|
||||
) {
|
||||
@@ -560,36 +558,6 @@ 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(
|
||||
|
||||
@@ -6,7 +6,6 @@ import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
@@ -17,11 +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,7 +30,6 @@ 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
|
||||
@@ -76,9 +75,8 @@ class EventDetailViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = EventDetailUiState.Loading,
|
||||
)
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ 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
|
||||
@@ -160,7 +161,6 @@ 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.
|
||||
EditCard(
|
||||
IconInfoCard(
|
||||
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.
|
||||
EditCard(
|
||||
IconInfoCard(
|
||||
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))
|
||||
EditCard(
|
||||
IconInfoCard(
|
||||
icon = Icons.Default.Public,
|
||||
iconContentDescription = null,
|
||||
onClick = { showTimezonePicker = true },
|
||||
@@ -1009,10 +1009,11 @@ private fun EventEditContent(
|
||||
|
||||
OptionalFormSection(visible = EventFormField.Location in state.visibleFields) {
|
||||
Spacer(Modifier.height(gap))
|
||||
EditCard(
|
||||
IconInfoCard(
|
||||
icon = Icons.Default.Place,
|
||||
iconContentDescription = stringResource(R.string.event_detail_location),
|
||||
iconAtTop = true,
|
||||
iconAlignment = Alignment.Top,
|
||||
iconTopPadding = ((firstLineHeight() - 24.dp) / 2).coerceAtLeast(0.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
@@ -1078,10 +1079,11 @@ private fun EventEditContent(
|
||||
|
||||
OptionalFormSection(visible = EventFormField.Description in state.visibleFields) {
|
||||
Spacer(Modifier.height(gap))
|
||||
EditCard(
|
||||
IconInfoCard(
|
||||
icon = Icons.AutoMirrored.Filled.Notes,
|
||||
iconContentDescription = stringResource(R.string.event_detail_description),
|
||||
iconAtTop = true,
|
||||
iconAlignment = Alignment.Top,
|
||||
iconTopPadding = ((firstLineHeight() - 24.dp) / 2).coerceAtLeast(0.dp),
|
||||
) {
|
||||
InlineField(
|
||||
value = form.description,
|
||||
@@ -1117,7 +1119,7 @@ private fun EventEditContent(
|
||||
|
||||
OptionalFormSection(visible = EventFormField.Recurrence in state.visibleFields) {
|
||||
Spacer(Modifier.height(gap))
|
||||
EditCard(
|
||||
IconInfoCard(
|
||||
icon = Icons.Default.Repeat,
|
||||
iconContentDescription = null,
|
||||
onClick = if (locked) null else ({ showRecurrencePicker = true }),
|
||||
@@ -1157,7 +1159,7 @@ private fun EventEditContent(
|
||||
|
||||
OptionalFormSection(visible = EventFormField.Availability in state.visibleFields) {
|
||||
Spacer(Modifier.height(gap))
|
||||
EditCard(
|
||||
IconInfoCard(
|
||||
icon = Icons.Default.EventAvailable,
|
||||
iconContentDescription = null,
|
||||
header = {
|
||||
@@ -1185,7 +1187,7 @@ private fun EventEditContent(
|
||||
|
||||
OptionalFormSection(visible = EventFormField.Visibility in state.visibleFields) {
|
||||
Spacer(Modifier.height(gap))
|
||||
EditCard(
|
||||
IconInfoCard(
|
||||
icon = Icons.Default.Lock,
|
||||
iconContentDescription = null,
|
||||
onClick = { showVisibilityPicker = true },
|
||||
@@ -1219,7 +1221,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
|
||||
EditCard(
|
||||
IconInfoCard(
|
||||
icon = Icons.Default.Palette,
|
||||
iconContentDescription = stringResource(R.string.event_edit_color),
|
||||
iconTint = if (colorSupported && swatch != null) {
|
||||
@@ -1672,12 +1674,7 @@ private fun RecurrencePickerDialog(
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.event_edit_recurrence_none),
|
||||
position = positionOf(0, rowCount),
|
||||
selected = current == null,
|
||||
trailing = if (current == null) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
checked = current == null,
|
||||
onClick = { onSelect(null) },
|
||||
)
|
||||
RecurrenceFreq.entries.forEachIndexed { index, entry ->
|
||||
@@ -1685,24 +1682,14 @@ private fun RecurrencePickerDialog(
|
||||
title = stringResource(recurrencePresetLabel(entry)),
|
||||
summary = nextOccurrencesText(SimpleRecurrence(entry), startDate, locale),
|
||||
position = positionOf(index + 1, rowCount),
|
||||
selected = isPlainPreset && parsed?.freq == entry,
|
||||
trailing = if (isPlainPreset && parsed?.freq == entry) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
checked = isPlainPreset && parsed?.freq == entry,
|
||||
onClick = { onSelect(SimpleRecurrence(entry).toRRule()) },
|
||||
)
|
||||
}
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.event_edit_recurrence_custom),
|
||||
position = positionOf(rowCount - 1, rowCount),
|
||||
selected = current != null && !isPlainPreset,
|
||||
trailing = if (current != null && !isPlainPreset) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
checked = current != null && !isPlainPreset,
|
||||
onClick = { customMode = true },
|
||||
)
|
||||
} else {
|
||||
@@ -1831,24 +1818,14 @@ private fun RecurrencePickerDialog(
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.event_edit_recurrence_end_never),
|
||||
position = Position.Top,
|
||||
selected = endMode == RecurrenceEndMode.Never,
|
||||
trailing = if (endMode == RecurrenceEndMode.Never) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
checked = endMode == RecurrenceEndMode.Never,
|
||||
onClick = { endMode = RecurrenceEndMode.Never },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.event_edit_recurrence_end_until),
|
||||
summary = untilSummary,
|
||||
position = Position.Middle,
|
||||
selected = endMode == RecurrenceEndMode.Until,
|
||||
trailing = if (endMode == RecurrenceEndMode.Until) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
checked = endMode == RecurrenceEndMode.Until,
|
||||
onClick = {
|
||||
endMode = RecurrenceEndMode.Until
|
||||
showUntilPicker = true
|
||||
@@ -1861,12 +1838,7 @@ private fun RecurrencePickerDialog(
|
||||
} else {
|
||||
Position.Bottom
|
||||
},
|
||||
selected = endMode == RecurrenceEndMode.Count,
|
||||
trailing = if (endMode == RecurrenceEndMode.Count) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
checked = endMode == RecurrenceEndMode.Count,
|
||||
onClick = { endMode = RecurrenceEndMode.Count },
|
||||
)
|
||||
// Animated for the same reason the weekday card is: this is the
|
||||
@@ -2024,13 +1996,7 @@ private fun GroupedItemCard(
|
||||
titleMaxLines: Int = Int.MAX_VALUE,
|
||||
trailing: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
shape = groupedShape(position),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(groupedGap(position)),
|
||||
) {
|
||||
GroupedSurface(position = position) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
|
||||
@@ -2381,85 +2347,6 @@ 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
|
||||
|
||||
@@ -7,7 +7,6 @@ import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
@@ -42,6 +41,8 @@ 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
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -338,9 +339,8 @@ class EventEditViewModel @Inject constructor(
|
||||
allowColorOnUnsupportedCalendars = external.allowColorOnUnsupported,
|
||||
)
|
||||
}
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
|
||||
@@ -4,20 +4,19 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import 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
|
||||
|
||||
@@ -45,9 +44,8 @@ class FilterViewModel @Inject constructor(
|
||||
}
|
||||
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = FilterUiState.Loading,
|
||||
)
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsImporter
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
@@ -15,6 +14,7 @@ import de.jeanlucmakiola.calendula.domain.ics.IcsParser
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.toEventForm
|
||||
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
||||
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
@@ -8,8 +8,8 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
|
||||
import de.jeanlucmakiola.calendula.ui.common.sampleMonthEvents
|
||||
import de.jeanlucmakiola.floret.components.ScaledViewPreview
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
@@ -4,7 +4,6 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
||||
import de.jeanlucmakiola.calendula.data.time.TodaySource
|
||||
@@ -19,10 +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,7 +33,6 @@ 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
|
||||
@@ -69,25 +68,22 @@ class MonthViewModel @Inject constructor(
|
||||
|
||||
/** Whether to fade events that have already finished (display concern only). */
|
||||
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
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
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
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
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = MonthViewStyle.Paged,
|
||||
)
|
||||
|
||||
@@ -103,7 +99,7 @@ class MonthViewModel @Inject constructor(
|
||||
lastToday = date
|
||||
if (old != date) followToday(old, date)
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), todayDate)
|
||||
.stateWhileSubscribed(viewModelScope, todayDate)
|
||||
|
||||
private val _month = MutableStateFlow(YearMonth(todayDate.year, todayDate.month))
|
||||
val month: StateFlow<YearMonth> = _month
|
||||
@@ -124,9 +120,8 @@ class MonthViewModel @Inject constructor(
|
||||
month(ym, ws).filterNot { it is MonthUiState.Loading }
|
||||
}
|
||||
.catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = MonthUiState.Loading,
|
||||
)
|
||||
|
||||
@@ -228,9 +223,8 @@ class MonthViewModel @Inject constructor(
|
||||
.onEach { if (it is ContinuousMonthUiState.Success) widenLoadedWindow() }
|
||||
.catch { emit(ContinuousMonthUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = ContinuousMonthUiState.Loading,
|
||||
)
|
||||
|
||||
|
||||
@@ -10,17 +10,14 @@ 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
|
||||
|
||||
@@ -53,15 +50,10 @@ internal fun BackupStep(
|
||||
topSpacing = OnboardingSpace.lg,
|
||||
hero = { IconHero(Icons.Filled.CloudOff) },
|
||||
actions = {
|
||||
Button(
|
||||
OnboardingPrimaryButton(
|
||||
text = stringResource(R.string.onboarding_backup_enable_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(),
|
||||
@@ -69,20 +61,9 @@ 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,7 +9,6 @@ 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
|
||||
@@ -30,6 +29,7 @@ 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,18 +108,13 @@ internal fun CalendarsStep(
|
||||
topSpacing = OnboardingSpace.lg,
|
||||
hero = { IconHero(Icons.Filled.CalendarMonth) },
|
||||
actions = {
|
||||
Button(
|
||||
OnboardingPrimaryButton(
|
||||
text = stringResource(R.string.onboarding_calendars_local_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),
|
||||
@@ -133,20 +128,9 @@ 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(
|
||||
@@ -189,30 +173,14 @@ private fun CalendarCreated(
|
||||
topSpacing = OnboardingSpace.lg,
|
||||
hero = { IconHero(Icons.Filled.CheckCircle) },
|
||||
actions = {
|
||||
Button(
|
||||
OnboardingPrimaryButton(
|
||||
text = stringResource(R.string.onboarding_calendars_created_continue),
|
||||
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,17 +1,10 @@
|
||||
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
|
||||
|
||||
@@ -34,28 +27,12 @@ internal fun DoneStep(
|
||||
topSpacing = OnboardingSpace.lg,
|
||||
hero = { BrandHero(HeroBadge.Done) },
|
||||
actions = {
|
||||
Button(
|
||||
OnboardingPrimaryButton(
|
||||
text = stringResource(R.string.onboarding_done_button),
|
||||
onClick = onFinished,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_done_button),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
)
|
||||
},
|
||||
) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
headline = stringResource(R.string.onboarding_done_title),
|
||||
supporting = stringResource(R.string.onboarding_done_body),
|
||||
)
|
||||
}
|
||||
|
||||
+11
-18
@@ -7,7 +7,6 @@ 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
|
||||
@@ -25,6 +24,7 @@ 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,24 +65,17 @@ internal fun HiddenCalendarsStep(
|
||||
scrollingActions = true,
|
||||
hero = { IconHero(Icons.Filled.VisibilityOff) },
|
||||
actions = {
|
||||
Button(
|
||||
OnboardingPrimaryButton(
|
||||
text = stringResource(
|
||||
if (anyVisible) {
|
||||
R.string.onboarding_view_continue_button
|
||||
} else {
|
||||
R.string.onboarding_hidden_switch_all_button
|
||||
},
|
||||
),
|
||||
onClick = if (anyVisible) onFinished else onSwitchAllOn,
|
||||
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,
|
||||
)
|
||||
}
|
||||
modifier = Modifier.padding(horizontal = GroupedListInset),
|
||||
)
|
||||
// Only while nothing is on: once something is, the step has been
|
||||
// answered and the one button left to press says so.
|
||||
if (!anyVisible) {
|
||||
|
||||
@@ -10,7 +10,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityReconciler
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
@@ -20,6 +19,8 @@ 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
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -92,9 +93,8 @@ class OnboardingViewModel @Inject constructor(
|
||||
list.catch { emit(null) }.flowOn(io)
|
||||
}
|
||||
}
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
@@ -183,9 +183,8 @@ class OnboardingViewModel @Inject constructor(
|
||||
*/
|
||||
val hiddenCalendars: StateFlow<List<CalendarSource>> = calendars
|
||||
.map { it.orEmpty().orderedForManager() }
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = emptyList(),
|
||||
)
|
||||
|
||||
@@ -232,9 +231,8 @@ class OnboardingViewModel @Inject constructor(
|
||||
doneShown = closing.doneShown,
|
||||
)
|
||||
}
|
||||
}.stateIn(
|
||||
}.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
@@ -244,9 +242,8 @@ class OnboardingViewModel @Inject constructor(
|
||||
prefs.monthViewStyle,
|
||||
prefs.weekStart,
|
||||
::ViewChoice,
|
||||
).stateIn(
|
||||
).stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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
|
||||
|
||||
@@ -13,16 +14,10 @@ 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
|
||||
@@ -33,8 +28,6 @@ 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(
|
||||
@@ -93,37 +86,15 @@ private fun RationaleContent(
|
||||
topSpacing = OnboardingSpace.lg,
|
||||
hero = { BrandHero() },
|
||||
actions = {
|
||||
Button(
|
||||
OnboardingPrimaryButton(
|
||||
text = stringResource(R.string.permission_request_button),
|
||||
onClick = onRequest,
|
||||
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),
|
||||
)
|
||||
}
|
||||
trailingIcon = Icons.AutoMirrored.Filled.ArrowForward,
|
||||
)
|
||||
},
|
||||
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(
|
||||
@@ -152,7 +123,8 @@ private fun DeniedContent(
|
||||
progress = progress,
|
||||
hero = { BrandHero(HeroBadge.Denied) },
|
||||
actions = {
|
||||
Button(
|
||||
OnboardingPrimaryButton(
|
||||
text = stringResource(R.string.permission_open_settings_button),
|
||||
onClick = {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.fromParts("package", context.packageName, null)
|
||||
@@ -160,13 +132,7 @@ 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(),
|
||||
@@ -174,18 +140,7 @@ private fun DeniedContent(
|
||||
Text(stringResource(R.string.permission_retry_button))
|
||||
}
|
||||
},
|
||||
) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
headline = stringResource(R.string.permission_denied_title),
|
||||
supporting = stringResource(R.string.permission_denied_body),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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,15 +15,11 @@ 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
|
||||
|
||||
/**
|
||||
@@ -53,7 +50,8 @@ fun ReminderStep(
|
||||
topSpacing = OnboardingSpace.lg,
|
||||
hero = { IconHero(Icons.Filled.NotificationsActive) },
|
||||
actions = {
|
||||
Button(
|
||||
OnboardingPrimaryButton(
|
||||
text = stringResource(R.string.reminder_onboarding_enable_button),
|
||||
onClick = {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
@@ -61,13 +59,7 @@ 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(),
|
||||
@@ -75,20 +67,9 @@ 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,8 +19,6 @@ 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.ViewPreviewFrame
|
||||
import de.jeanlucmakiola.calendula.ui.common.icon
|
||||
import de.jeanlucmakiola.calendula.ui.common.label
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
|
||||
@@ -28,11 +26,12 @@ 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
|
||||
|
||||
@@ -77,11 +76,10 @@ internal fun ViewStep(
|
||||
}
|
||||
}
|
||||
IMPLEMENTED_VIEWS.forEachIndexed { index, view ->
|
||||
val isSelected = view == choice.defaultView
|
||||
GroupedRow(
|
||||
title = view.label(),
|
||||
position = positionOf(index, IMPLEMENTED_VIEWS.size),
|
||||
selected = isSelected,
|
||||
checked = view == choice.defaultView,
|
||||
leading = {
|
||||
Icon(
|
||||
imageVector = view.icon,
|
||||
@@ -89,11 +87,6 @@ internal fun ViewStep(
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
trailing = if (isSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { onSelect(view) },
|
||||
)
|
||||
}
|
||||
@@ -127,18 +120,12 @@ internal fun MonthStyleStep(
|
||||
StepPreview(selected = choice.monthViewStyle) { style ->
|
||||
MonthStylePreview(style = style, weekStart = weekStart, height = PREVIEW_HEIGHT)
|
||||
}
|
||||
PickerDescription(stringResource(choice.monthViewStyle.descriptionRes))
|
||||
GroupedHint(stringResource(choice.monthViewStyle.descriptionRes), verticalPadding = 8.dp)
|
||||
options.forEachIndexed { index, style ->
|
||||
val isSelected = style == choice.monthViewStyle
|
||||
GroupedRow(
|
||||
title = stringResource(style.labelRes),
|
||||
position = positionOf(index, options.size),
|
||||
selected = isSelected,
|
||||
trailing = if (isSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
checked = style == choice.monthViewStyle,
|
||||
onClick = { onSelect(style) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
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
|
||||
|
||||
@@ -38,28 +31,12 @@ internal fun VisibilityStep(
|
||||
topSpacing = OnboardingSpace.lg,
|
||||
hero = { IconHero(Icons.Filled.VisibilityOff) },
|
||||
actions = {
|
||||
Button(
|
||||
OnboardingPrimaryButton(
|
||||
text = stringResource(R.string.onboarding_visibility_button),
|
||||
onClick = onFinished,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_visibility_button),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
)
|
||||
},
|
||||
) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
headline = stringResource(R.string.calendars_visibility_notice_title),
|
||||
supporting = stringResource(R.string.calendars_visibility_notice_message),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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
|
||||
@@ -72,7 +71,6 @@ 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
|
||||
@@ -82,6 +80,7 @@ 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
|
||||
@@ -166,10 +165,7 @@ fun SearchScreen(
|
||||
pendingWrite = null
|
||||
}
|
||||
val requireWrite: (() -> Unit) -> Unit = { action ->
|
||||
val granted = ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.WRITE_CALENDAR,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
val granted = context.hasPermission(Manifest.permission.WRITE_CALENDAR)
|
||||
if (granted) {
|
||||
action()
|
||||
} else {
|
||||
|
||||
@@ -4,17 +4,17 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.domain.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,7 +25,6 @@ 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
|
||||
@@ -120,7 +119,7 @@ class SearchViewModel @Inject constructor(
|
||||
}
|
||||
.catch { emit(SearchUiState.Idle) }
|
||||
.flowOn(io)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), SearchUiState.Idle)
|
||||
.stateWhileSubscribed(viewModelScope, 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,17 +127,12 @@ internal fun AppearanceScreen(
|
||||
null
|
||||
},
|
||||
)
|
||||
GroupedRow(
|
||||
GroupedSwitchRow(
|
||||
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))
|
||||
@@ -209,7 +204,7 @@ internal fun AppearanceScreen(
|
||||
val systemDark = isSystemInDarkTheme()
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_theme),
|
||||
header = { PickerDescription(stringResource(R.string.settings_theme_hint)) },
|
||||
header = { GroupedHint(stringResource(R.string.settings_theme_hint), verticalPadding = 8.dp) },
|
||||
predictiveBack = true,
|
||||
options = ThemeMode.entries,
|
||||
selected = state.themeMode,
|
||||
@@ -407,7 +402,7 @@ private fun FontOptionRow(
|
||||
GroupedRow(
|
||||
title = label,
|
||||
position = position,
|
||||
selected = selected,
|
||||
checked = selected,
|
||||
leading = {
|
||||
if (preview != null) {
|
||||
Text(
|
||||
@@ -419,11 +414,6 @@ private fun FontOptionRow(
|
||||
Icon(imageVector = leadingIcon, contentDescription = null)
|
||||
}
|
||||
},
|
||||
trailing = if (selected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,13 +5,10 @@ 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
|
||||
@@ -29,7 +26,10 @@ 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,13 +56,15 @@ internal fun EventFormScreen(
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
SettingsHint(stringResource(R.string.settings_form_fields_hint))
|
||||
GroupedHint(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
|
||||
GroupedRow(
|
||||
GroupedSwitchRow(
|
||||
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 = {
|
||||
@@ -72,20 +74,15 @@ 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))
|
||||
GroupedRow(
|
||||
GroupedSwitchRow(
|
||||
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 = {
|
||||
@@ -95,13 +92,6 @@ 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 —
|
||||
@@ -127,13 +117,7 @@ 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 = {
|
||||
Icon(
|
||||
imageVector = if (durationSectionExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
trailing = { ExpandIcon(durationSectionExpanded) },
|
||||
onClick = { durationSectionExpanded = !durationSectionExpanded },
|
||||
)
|
||||
AnimatedVisibility(
|
||||
@@ -165,21 +149,12 @@ 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))
|
||||
GroupedRow(
|
||||
GroupedSwitchRow(
|
||||
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,16 +6,14 @@ 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.common.ViewPreviewFrame
|
||||
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.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.components.GroupedHint
|
||||
import de.jeanlucmakiola.floret.components.OptionRows
|
||||
import de.jeanlucmakiola.floret.components.ViewPreviewFrame
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
|
||||
/**
|
||||
@@ -29,7 +27,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 [SelectedCheck] on the current one.
|
||||
* tonal highlight and a check on the current one.
|
||||
*/
|
||||
@Composable
|
||||
internal fun MonthViewStylePicker(
|
||||
@@ -58,23 +56,15 @@ 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.
|
||||
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) },
|
||||
)
|
||||
}
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ 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
|
||||
@@ -15,11 +14,8 @@ 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
|
||||
@@ -30,7 +26,6 @@ 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
|
||||
@@ -42,13 +37,17 @@ 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
|
||||
|
||||
@@ -73,9 +72,7 @@ internal fun NotificationsScreen(
|
||||
viewModel.setRemindersEnabled(enabled)
|
||||
val needsPermission = enabled &&
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.POST_NOTIFICATIONS,
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
!context.hasPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
if (needsPermission) {
|
||||
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
@@ -95,14 +92,12 @@ internal fun NotificationsScreen(
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
GroupedRow(
|
||||
GroupedSwitchRow(
|
||||
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),
|
||||
@@ -162,13 +157,7 @@ internal fun NotificationsScreen(
|
||||
title = stringResource(R.string.settings_calendar_reminders_title),
|
||||
summary = stringResource(R.string.settings_calendar_reminders_hint),
|
||||
position = Position.Alone,
|
||||
trailing = {
|
||||
Icon(
|
||||
imageVector = if (calendarSectionExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
trailing = { ExpandIcon(calendarSectionExpanded) },
|
||||
onClick = { calendarSectionExpanded = !calendarSectionExpanded },
|
||||
)
|
||||
AnimatedVisibility(
|
||||
@@ -203,13 +192,7 @@ 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 = {
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
trailing = { ExpandIcon(expanded) },
|
||||
onClick = {
|
||||
expandedCalendars = if (expanded) {
|
||||
expandedCalendars - calendar.id
|
||||
@@ -263,7 +246,7 @@ internal fun NotificationsScreen(
|
||||
presets = REMINDER_PRESETS,
|
||||
selected = state.defaultReminderMinutes.toReminderChoice(),
|
||||
allowInherit = false,
|
||||
onSelect = { viewModel.setDefaultReminderMinutes(it.toMinutesList()) },
|
||||
onSelect = { viewModel.setDefaultReminderMinutes(it.minutesOrEmpty()) },
|
||||
onDismiss = { showDefaultReminder = false },
|
||||
)
|
||||
}
|
||||
@@ -273,7 +256,7 @@ internal fun NotificationsScreen(
|
||||
presets = ALLDAY_REMINDER_PRESETS,
|
||||
selected = state.defaultAllDayReminderMinutes.toReminderChoice(),
|
||||
allowInherit = false,
|
||||
onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.toMinutesList()) },
|
||||
onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.minutesOrEmpty()) },
|
||||
onDismiss = { showAllDayReminder = false },
|
||||
leadTimeSummary = allDayFiringTimeSummary(state.allDayReminderTimeMinutes),
|
||||
)
|
||||
@@ -332,10 +315,6 @@ 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,10 +27,9 @@ 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.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.components.GroupedHint
|
||||
import de.jeanlucmakiola.floret.components.OptionRows
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import kotlinx.datetime.LocalDate
|
||||
@@ -80,21 +79,13 @@ internal fun PastEventsPicker(
|
||||
}
|
||||
}
|
||||
}
|
||||
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) },
|
||||
)
|
||||
}
|
||||
GroupedHint(stringResource(R.string.settings_past_events_hint), verticalPadding = 8.dp)
|
||||
OptionRows(
|
||||
options = options,
|
||||
selected = selected,
|
||||
label = { stringResource(pastEventDisplayLabelRes(it)) },
|
||||
onSelect = onSelect,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,10 @@ 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
|
||||
@@ -22,7 +20,6 @@ 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.locale.currentLocale
|
||||
|
||||
/**
|
||||
@@ -63,36 +60,6 @@ internal fun CategoryIcon(icon: ImageVector, accent: ChipAccent) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A small primary-coloured group label, matching the Calendars settings screen.
|
||||
* Inset to [GroupedListInset] so it shares the cards' left margin.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SectionHeader(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(
|
||||
start = GroupedListInset,
|
||||
end = GroupedListInset,
|
||||
top = 16.dp,
|
||||
bottom = 4.dp,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Muted supporting text under a [SectionHeader], 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) }
|
||||
|
||||
@@ -58,14 +58,14 @@ import de.jeanlucmakiola.floret.components.AboutCard
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||
import de.jeanlucmakiola.floret.components.LanguagePickerRow
|
||||
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 {
|
||||
@@ -190,7 +190,7 @@ private fun SettingsHub(
|
||||
|
||||
// Each group cycles its chip accents so no two rows in it look alike
|
||||
// (see [ChipAccent]).
|
||||
SectionHeader(stringResource(R.string.settings_group_look))
|
||||
GroupedSectionHeader(stringResource(R.string.settings_group_look))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_section_appearance),
|
||||
summary = stringResource(R.string.settings_appearance_subtitle),
|
||||
@@ -221,7 +221,7 @@ private fun SettingsHub(
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SectionHeader(stringResource(R.string.settings_group_data))
|
||||
GroupedSectionHeader(stringResource(R.string.settings_group_data))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_section_calendars),
|
||||
summary = stringResource(R.string.settings_manage_calendars_hint),
|
||||
@@ -247,7 +247,7 @@ private fun SettingsHub(
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SectionHeader(stringResource(R.string.settings_group_app))
|
||||
GroupedSectionHeader(stringResource(R.string.settings_group_app))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_section_widgets),
|
||||
summary = stringResource(R.string.settings_widgets_subtitle),
|
||||
@@ -255,13 +255,33 @@ private fun SettingsHub(
|
||||
leading = { CategoryIcon(Icons.Default.Widgets, ChipAccent.Tertiary) },
|
||||
onClick = { onOpenSection(SettingsSection.Widgets) },
|
||||
)
|
||||
LanguageRow(position = Position.Middle)
|
||||
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) },
|
||||
)
|
||||
ReportProblemRow(position = Position.Bottom)
|
||||
|
||||
// Source, licence and privacy sit at the bottom as reference material
|
||||
// rather than inside the About card.
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SectionHeader(stringResource(R.string.settings_group_about))
|
||||
GroupedSectionHeader(stringResource(R.string.settings_group_about))
|
||||
AboutLinkRow(
|
||||
label = stringResource(R.string.settings_about_source),
|
||||
url = stringResource(R.string.about_source_url),
|
||||
@@ -327,58 +347,6 @@ 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
|
||||
|
||||
@@ -17,8 +17,9 @@ import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
||||
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.di.IoDispatcher
|
||||
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
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
@@ -54,12 +55,10 @@ 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
|
||||
@@ -230,9 +229,8 @@ class SettingsViewModel @Inject constructor(
|
||||
defaultEventDurationMinutes = overrides.durations.default,
|
||||
perCalendarEventDuration = overrides.durations.perCalendar,
|
||||
)
|
||||
}.stateIn(
|
||||
}.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
|
||||
)
|
||||
|
||||
@@ -248,9 +246,8 @@ class SettingsViewModel @Inject constructor(
|
||||
prefs.plainFontStamp,
|
||||
) { brand, plain, brandStamp, plainStamp ->
|
||||
AppFontSettings(brand = brand, plain = plain, brandStamp = brandStamp, plainStamp = plainStamp)
|
||||
}.stateIn(
|
||||
}.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = AppFontSettings(),
|
||||
)
|
||||
|
||||
@@ -373,9 +370,8 @@ class SettingsViewModel @Inject constructor(
|
||||
allDayOverrides.reminderOverrideFor(calendarId)
|
||||
},
|
||||
)
|
||||
}.stateIn(
|
||||
}.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = SpecialDatesUiState(),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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
|
||||
@@ -14,7 +13,6 @@ 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
|
||||
@@ -34,9 +32,12 @@ 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)
|
||||
@@ -106,38 +107,26 @@ internal fun SpecialDatesScreen(
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
GroupedRow(
|
||||
GroupedSwitchRow(
|
||||
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
|
||||
GroupedRow(
|
||||
GroupedSwitchRow(
|
||||
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(
|
||||
@@ -148,7 +137,7 @@ internal fun SpecialDatesScreen(
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_special_dates_reminders),
|
||||
summary = reminderChoiceLabel(specialDatesReminderMinutes(state.reminderChoices[type])),
|
||||
summary = reminderChoiceLabel(state.reminderChoices[type]?.minutesOrEmpty().orEmpty()),
|
||||
position = Position.Bottom,
|
||||
onClick = { reminderPickerType = type },
|
||||
)
|
||||
@@ -156,21 +145,16 @@ internal fun SpecialDatesScreen(
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
GroupedRow(
|
||||
GroupedSwitchRow(
|
||||
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(context, state.lastRun),
|
||||
summary = specialDatesLastRunLabel(state.lastRun),
|
||||
position = Position.Bottom,
|
||||
onClick = viewModel::syncSpecialDatesNow,
|
||||
)
|
||||
@@ -223,10 +207,6 @@ 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,
|
||||
@@ -305,16 +285,12 @@ private fun specialDateTypeLabel(type: SpecialDateType): Int = when (type) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun specialDatesLastRunLabel(context: Context, lastRun: Long): String =
|
||||
private fun specialDatesLastRunLabel(lastRun: Long): String =
|
||||
if (lastRun <= 0L) {
|
||||
stringResource(R.string.settings_special_dates_never_synced)
|
||||
} else {
|
||||
stringResource(
|
||||
R.string.settings_special_dates_last_synced,
|
||||
android.text.format.DateUtils.getRelativeTimeSpanString(
|
||||
lastRun,
|
||||
System.currentTimeMillis(),
|
||||
android.text.format.DateUtils.MINUTE_IN_MILLIS,
|
||||
).toString(),
|
||||
relativeTimeAgo(lastRun),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@ 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
|
||||
@@ -46,7 +45,10 @@ 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
|
||||
@@ -92,7 +94,7 @@ internal fun ViewsScreen(
|
||||
val config = state.quickSwitchConfig
|
||||
|
||||
// What holds for every view, above the per-view groups.
|
||||
SectionHeader(stringResource(R.string.settings_views_all_header))
|
||||
GroupedSectionHeader(stringResource(R.string.settings_views_all_header))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_view),
|
||||
summary = state.defaultView.label(),
|
||||
@@ -111,77 +113,57 @@ internal fun ViewsScreen(
|
||||
position = Position.Middle,
|
||||
onClick = { showTimeFormat = true },
|
||||
)
|
||||
GroupedRow(
|
||||
GroupedSwitchRow(
|
||||
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) },
|
||||
)
|
||||
GroupedRow(
|
||||
GroupedSwitchRow(
|
||||
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))
|
||||
|
||||
// How events respond to a tap and a long press (#178, #141).
|
||||
SectionHeader(stringResource(R.string.settings_events_header))
|
||||
GroupedSectionHeader(stringResource(R.string.settings_events_header))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_tap_opens),
|
||||
summary = tapOpensLabel(state.tapOpensEditor),
|
||||
position = Position.Top,
|
||||
onClick = { showTapOpens = true },
|
||||
)
|
||||
GroupedRow(
|
||||
GroupedSwitchRow(
|
||||
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))
|
||||
SectionHeader(stringResource(R.string.settings_month_header))
|
||||
GroupedSectionHeader(stringResource(R.string.settings_month_header))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_month_view_style),
|
||||
summary = stringResource(state.monthViewStyle.labelRes),
|
||||
position = Position.Top,
|
||||
onClick = { showMonthStyle = true },
|
||||
)
|
||||
GroupedRow(
|
||||
GroupedSwitchRow(
|
||||
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))
|
||||
SectionHeader(stringResource(R.string.settings_week_day_header))
|
||||
GroupedSectionHeader(stringResource(R.string.settings_week_day_header))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_multi_day_count),
|
||||
summary = multiDayLabel(state.multiDayCount),
|
||||
@@ -199,8 +181,10 @@ internal fun ViewsScreen(
|
||||
dimmed = hours.enabled,
|
||||
onClick = if (hours.enabled) null else ({ showTimelineScale = true }),
|
||||
)
|
||||
GroupedRow(
|
||||
GroupedSwitchRow(
|
||||
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,
|
||||
@@ -211,13 +195,6 @@ 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(
|
||||
@@ -233,21 +210,16 @@ internal fun ViewsScreen(
|
||||
onClick = { showHoursEnd = true },
|
||||
)
|
||||
}
|
||||
GroupedRow(
|
||||
GroupedSwitchRow(
|
||||
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))
|
||||
SectionHeader(stringResource(R.string.settings_agenda_header))
|
||||
GroupedSectionHeader(stringResource(R.string.settings_agenda_header))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_agenda_range),
|
||||
summary = agendaRangeLabel(state.agendaScreenRange),
|
||||
@@ -260,34 +232,24 @@ internal fun ViewsScreen(
|
||||
position = Position.Middle,
|
||||
onClick = { showPastEvents = true },
|
||||
)
|
||||
GroupedRow(
|
||||
GroupedSwitchRow(
|
||||
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) },
|
||||
)
|
||||
GroupedRow(
|
||||
GroupedSwitchRow(
|
||||
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))
|
||||
SectionHeader(stringResource(R.string.settings_quick_switch_header))
|
||||
SettingsHint(stringResource(R.string.settings_quick_switch_hint))
|
||||
GroupedSectionHeader(stringResource(R.string.settings_quick_switch_header))
|
||||
GroupedHint(stringResource(R.string.settings_quick_switch_hint))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
ReorderableColumn(
|
||||
items = config.order,
|
||||
@@ -311,8 +273,8 @@ internal fun ViewsScreen(
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
SectionHeader(stringResource(R.string.settings_drawer_order_header))
|
||||
SettingsHint(stringResource(R.string.settings_drawer_order_hint))
|
||||
GroupedSectionHeader(stringResource(R.string.settings_drawer_order_header))
|
||||
GroupedHint(stringResource(R.string.settings_drawer_order_hint))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
ReorderableColumn(
|
||||
items = state.drawerViewOrder,
|
||||
@@ -340,7 +302,7 @@ internal fun ViewsScreen(
|
||||
if (showDefaultView) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_default_view),
|
||||
header = { PickerDescription(stringResource(R.string.settings_default_view_hint)) },
|
||||
header = { GroupedHint(stringResource(R.string.settings_default_view_hint), verticalPadding = 8.dp) },
|
||||
predictiveBack = true,
|
||||
options = IMPLEMENTED_VIEWS,
|
||||
selected = state.defaultView,
|
||||
@@ -371,7 +333,7 @@ internal fun ViewsScreen(
|
||||
val systemIs24Hour = DateFormat.is24HourFormat(LocalContext.current)
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_time_format),
|
||||
header = { PickerDescription(stringResource(R.string.settings_time_format_hint)) },
|
||||
header = { GroupedHint(stringResource(R.string.settings_time_format_hint), verticalPadding = 8.dp) },
|
||||
predictiveBack = true,
|
||||
options = TimeFormatPref.entries,
|
||||
selected = state.timeFormat,
|
||||
@@ -402,7 +364,7 @@ internal fun ViewsScreen(
|
||||
if (showTapOpens) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_tap_opens),
|
||||
header = { PickerDescription(stringResource(R.string.settings_tap_opens_hint)) },
|
||||
header = { GroupedHint(stringResource(R.string.settings_tap_opens_hint), verticalPadding = 8.dp) },
|
||||
predictiveBack = true,
|
||||
options = listOf(false, true),
|
||||
selected = state.tapOpensEditor,
|
||||
@@ -421,7 +383,7 @@ internal fun ViewsScreen(
|
||||
if (showMultiDayCount) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_multi_day_count),
|
||||
header = { PickerDescription(stringResource(R.string.settings_multi_day_count_hint)) },
|
||||
header = { GroupedHint(stringResource(R.string.settings_multi_day_count_hint), verticalPadding = 8.dp) },
|
||||
predictiveBack = true,
|
||||
options = SettingsPrefs.MULTI_DAY_COUNTS.toList(),
|
||||
selected = state.multiDayCount,
|
||||
@@ -433,7 +395,7 @@ internal fun ViewsScreen(
|
||||
if (showTimelineScale) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_timeline_scale),
|
||||
header = { PickerDescription(stringResource(R.string.settings_timeline_scale_hint)) },
|
||||
header = { GroupedHint(stringResource(R.string.settings_timeline_scale_hint), verticalPadding = 8.dp) },
|
||||
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
|
||||
@@ -452,7 +414,7 @@ internal fun ViewsScreen(
|
||||
if (showHoursStart) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_visible_hours_start),
|
||||
header = { PickerDescription(stringResource(R.string.settings_visible_hours_start_hint)) },
|
||||
header = { GroupedHint(stringResource(R.string.settings_visible_hours_start_hint), verticalPadding = 8.dp) },
|
||||
predictiveBack = true,
|
||||
options = TimelineHours.startOptions(hours.endHour),
|
||||
selected = hours.startHour,
|
||||
@@ -464,7 +426,7 @@ internal fun ViewsScreen(
|
||||
if (showHoursEnd) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_visible_hours_end),
|
||||
header = { PickerDescription(stringResource(R.string.settings_visible_hours_end_hint)) },
|
||||
header = { GroupedHint(stringResource(R.string.settings_visible_hours_end_hint), verticalPadding = 8.dp) },
|
||||
predictiveBack = true,
|
||||
options = TimelineHours.endOptions(hours.startHour),
|
||||
selected = hours.endHour,
|
||||
|
||||
@@ -8,14 +8,12 @@ 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.common.ViewPreviewFrame
|
||||
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.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.components.GroupedHint
|
||||
import de.jeanlucmakiola.floret.components.OptionRows
|
||||
import de.jeanlucmakiola.floret.components.ViewPreviewFrame
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
|
||||
/**
|
||||
@@ -51,29 +49,23 @@ internal fun WeekStartPicker(
|
||||
height = PREVIEW_HEIGHT,
|
||||
)
|
||||
}
|
||||
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) {
|
||||
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) {
|
||||
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,
|
||||
) {
|
||||
SettingsHint(stringResource(R.string.settings_widgets_hint))
|
||||
GroupedHint(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 = { PickerDescription(stringResource(R.string.settings_widget_size_hint)) },
|
||||
header = { GroupedHint(stringResource(R.string.settings_widget_size_hint), verticalPadding = 8.dp) },
|
||||
predictiveBack = true,
|
||||
options = WidgetSize.entries,
|
||||
selected = state.widgetSize,
|
||||
|
||||
@@ -4,7 +4,6 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.data.time.TodaySource
|
||||
@@ -16,6 +15,8 @@ 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
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -97,17 +98,15 @@ abstract class PagedTimelineViewModel(
|
||||
|
||||
/** Whether to fade events that have already finished (display concern only). */
|
||||
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
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
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = SettingsPrefs.DEFAULT_EVENT_DURATION,
|
||||
)
|
||||
|
||||
@@ -135,14 +134,13 @@ abstract class PagedTimelineViewModel(
|
||||
lastToday = date
|
||||
if (old != date) followToday(old, date)
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), todayDate)
|
||||
.stateWhileSubscribed(viewModelScope, todayDate)
|
||||
|
||||
private val position: StateFlow<PagerPosition> =
|
||||
combine(_anchor, rule) { anchor, rule -> positionOf(anchor, rule) }
|
||||
.distinctUntilChanged()
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = positionOf(todayDate, initialRule),
|
||||
)
|
||||
|
||||
@@ -156,9 +154,8 @@ abstract class PagedTimelineViewModel(
|
||||
.distinctUntilChanged()
|
||||
.flatMapLatest { span -> span(span).filterNot { it is WeekUiState.Loading } }
|
||||
.catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = WeekUiState.Loading,
|
||||
)
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
||||
import de.jeanlucmakiola.calendula.ui.common.sampleTimelineEvents
|
||||
import de.jeanlucmakiola.floret.components.ScaledViewPreview
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
Reference in New Issue
Block a user