diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5eaf14f..fc20dc1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -175,7 +175,9 @@ dependencies { implementation("de.jeanlucmakiola.floret:core-time") implementation("de.jeanlucmakiola.floret:core-locale") implementation("de.jeanlucmakiola.floret:core-crash") + implementation("de.jeanlucmakiola.floret:core-di") implementation("de.jeanlucmakiola.floret:core-reminders") + implementation("de.jeanlucmakiola.floret:core-android") implementation("de.jeanlucmakiola.floret:identity") implementation("de.jeanlucmakiola.floret:components") implementation(libs.kotlinx.coroutines.core) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/backup/AutoBackup.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/backup/AutoBackup.kt index 64f5091..8c504d9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/backup/AutoBackup.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/backup/AutoBackup.kt @@ -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( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt index 78c2d78..d938951 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt @@ -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() 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() diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt index 3c59714..77bdc39 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarVisibilityReconciler.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarVisibilityReconciler.kt index 6678eeb..4d3518d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarVisibilityReconciler.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarVisibilityReconciler.kt @@ -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) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/ContactSpecialDatesDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/ContactSpecialDatesDataSource.kt index f4edca0..d8c6997 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/ContactSpecialDatesDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/ContactSpecialDatesDataSource.kt @@ -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) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/di/DataModule.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/di/DataModule.kt index 15f923d..bc72c81 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/di/DataModule.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/di/DataModule.kt @@ -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 by preferencesDataStore( @@ -87,8 +85,4 @@ object DataProvideModule { @Singleton fun provideDataStore(@ApplicationContext context: Context): DataStore = context.calendulaDataStore - - @Provides - @IoDispatcher - fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/di/Qualifiers.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/di/Qualifiers.kt deleted file mode 100644 index 47ad77d..0000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/di/Qualifiers.kt +++ /dev/null @@ -1,7 +0,0 @@ -package de.jeanlucmakiola.calendula.data.di - -import javax.inject.Qualifier - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class IoDispatcher diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index d051ab9..5b66b08 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -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 = 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()), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderActionReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderActionReceiver.kt index d62ee0d..b4d5e94 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderActionReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderActionReceiver.kt @@ -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) } } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderAlarms.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderAlarms.kt index 1fd5a75..735d091 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderAlarms.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderAlarms.kt @@ -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() ?: 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. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt index 8a1009b..07a0004 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt @@ -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() } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderScanner.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderScanner.kt index 3c89ca9..38ea963 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderScanner.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderScanner.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderScheduleReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderScheduleReceiver.kt index 0df3eab..4789ee9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderScheduleReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderScheduleReceiver.kt @@ -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 { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderSnoozeScheduler.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderSnoozeScheduler.kt index 694f052..0c9e05c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderSnoozeScheduler.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderSnoozeScheduler.kt @@ -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() } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/time/TodaySource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/time/TodaySource.kt index 9ff1394..0653c41 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/time/TodaySource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/time/TodaySource.kt @@ -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 = 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 = context + .broadcastTicks(Intent.ACTION_DATE_CHANGED, Intent.ACTION_TIME_CHANGED, Intent.ACTION_TIMEZONE_CHANGED) .transformLatest { while (true) { emit(currentDate()) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/time/ZoneSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/time/ZoneSource.kt index 5505ddd..e4ca03f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/time/ZoneSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/time/ZoneSource.kt @@ -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 = 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 = context.broadcastTicks(Intent.ACTION_TIMEZONE_CHANGED) .map { current() } .distinctUntilChanged() diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHostViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHostViewModel.kt index ba9d915..cba2890 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHostViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHostViewModel.kt @@ -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 = todaySource.today.stateIn( + val today: StateFlow = todaySource.today.stateWhileSubscribed( scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000L), initialValue = currentDate(), ) - val defaultView: StateFlow = prefs.defaultView.stateIn( + val defaultView: StateFlow = 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> = 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> = 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 = 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 = prefs.dragToReschedule - .stateIn( + .stateWhileSubscribed( scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000L), initialValue = false, ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt index 62f034f..c29f0ab 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt @@ -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. diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt index 785a400..4915f16 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt @@ -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 = 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 = 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, ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewPreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewPreview.kt index 953db71..452c848 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewPreview.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewPreview.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt index 5639022..0278279 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt @@ -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 { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt index 1c761e7..e070249 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt index 38eadec..861f452 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt @@ -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(), ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt index 802bbb8..d9a6ce9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt @@ -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) }, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActions.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActions.kt index 4527911..b9bef70 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActions.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActions.kt @@ -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( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActionsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActionsViewModel.kt index 5a5d2df..e2da8e5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActionsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActionsViewModel.kt @@ -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(), ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PageStateCache.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PageStateCache.kt index 8358820..5e92a83 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PageStateCache.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PageStateCache.kt @@ -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( 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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt index 41c857d..f2505b2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt @@ -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, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt index 72d58e3..ba1f245 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt @@ -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()) } .flowOn(io) - .stateIn( + .stateWhileSubscribed( scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000L), initialValue = emptySet(), ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt index 33c29f8..dc055fc 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt @@ -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, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewPreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewPreview.kt deleted file mode 100644 index 670734e..0000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewPreview.kt +++ /dev/null @@ -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 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) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index eb8fcc4..1e26b19 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -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( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt index 95369b5..fdf3247 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt @@ -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, ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index fc35dcc..f1b7a1e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt index b96db93..aee0e8d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt @@ -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, ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/FilterViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/FilterViewModel.kt index 218c364..5d615c4 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/FilterViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/FilterViewModel.kt @@ -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, ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt index 8dceb1f..7dba1bf 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt index ffe8d27..67f13db 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt index f6ef83b..f59c4e3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt @@ -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 = 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 = 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 = 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 = _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, ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/BackupStep.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/BackupStep.kt index 87627f1..65001ed 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/BackupStep.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/BackupStep.kt @@ -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( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/CalendarsStep.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/CalendarsStep.kt index 97984eb..ab37d67 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/CalendarsStep.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/CalendarsStep.kt @@ -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( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/DoneStep.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/DoneStep.kt index 59c5154..78e7863 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/DoneStep.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/DoneStep.kt @@ -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), + ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/HiddenCalendarsStep.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/HiddenCalendarsStep.kt index 472d336..86b5775 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/HiddenCalendarsStep.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/HiddenCalendarsStep.kt @@ -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) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingViewModel.kt index e6cbf3e..5828660 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingViewModel.kt @@ -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> = 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, ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/PermissionScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/PermissionScreen.kt index 0df89a9..7df5f0a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/PermissionScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/PermissionScreen.kt @@ -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), + ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ReminderStep.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ReminderStep.kt index ef53c5a..f5f121a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ReminderStep.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ReminderStep.kt @@ -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( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ViewStep.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ViewStep.kt index defdd8b..1d512e2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ViewStep.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ViewStep.kt @@ -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) }, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/VisibilityStep.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/VisibilityStep.kt index d509766..54dfe9d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/VisibilityStep.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/VisibilityStep.kt @@ -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), + ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt index c4777e3..3caa4c0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt @@ -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 { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModel.kt index d410f74..76d697f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModel.kt @@ -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() diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppearanceSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppearanceSettings.kt index 6febcc4..83dd815 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppearanceSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppearanceSettings.kt @@ -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, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/EventFormSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/EventFormSettings.kt index f7d0b39..4d44c31 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/EventFormSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/EventFormSettings.kt @@ -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, - ) - }, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/MonthViewStylePicker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/MonthViewStylePicker.kt index 4fd7986..c780a73 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/MonthViewStylePicker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/MonthViewStylePicker.kt @@ -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, + ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt index 7fe559f..bea57ba 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt @@ -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.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 = - (this as? ReminderOverride.Minutes)?.minutes ?: emptyList() - /** Row summary for a calendar: its override, or the inherited global default. */ @Composable private fun calendarOverrideSummary( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/PastEventsPicker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/PastEventsPicker.kt index 4b34acb..33b9fa3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/PastEventsPicker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/PastEventsPicker.kt @@ -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, + ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsCommon.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsCommon.kt index ebba693..746ff28 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsCommon.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsCommon.kt @@ -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) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index a24d9e3..2a6c1f7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -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(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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index 5bee5a8..e4c47f3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -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(), ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SpecialDatesSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SpecialDatesSettings.kt index 0e8559b..2f20e9a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SpecialDatesSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SpecialDatesSettings.kt @@ -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 = - (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), ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt index 1f8bf67..2dac5c6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt @@ -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, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WeekStartPicker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WeekStartPicker.kt index 543a67b..a7682fb 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WeekStartPicker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WeekStartPicker.kt @@ -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) }, - ) - } + } + }, + ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt index d8301cc..456efbe 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt @@ -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, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt index d929dd4..5fcbe92 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt @@ -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 = 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 = 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 = 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, ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt index ace125e..4936177 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt @@ -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 diff --git a/floret-kit b/floret-kit index b1905da..5093e1c 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit b1905dac00e554fcb80eb73049e27c2e9b9553f7 +Subproject commit 5093e1cc5fe40c766f1354fc87762080051241b8