Fix issues from the 2.23.0 branch review (#428)
Fixes from reviewing the 2.23.0 release branch, plus a second pass over those fixes. - Moved recurring exceptions keep their colour when they change calendar, instead of losing their colour key. - An external request (widget, edit intent, share) now resets the form it replaces. Forms reset only after their slide-out ends, so they never go blank on the way out. - Import, duplicate and picker state survive rotation, and recreating the activity no longer replays the launch intent. - A shared .ics attachment opens the import, even when the email app adds a subject line as text. Plain text still opens a new event. - Stale import state no longer reopens the previous import's form. - The theme and dynamic colour are cached so a cold start draws the right colours from the first frame. - Write-permission prompts (tap to edit, delete, duplicate, selection actions, week drafts) keep their pending action across rotation, and tap to edit falls back to details when the prompt is refused. - Zone and date sources share one app-scoped receiver instead of one per collector. - Scrolling back over an empty agenda past grows the window by at most a year per step, not exponentially. - RRULE part names and the RRULE: prefix are matched case-insensitively on import. - Search closes when another overlay takes over. Closes #306, #178, #387, #367 Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de> Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/428
This commit is contained in:
@@ -152,7 +152,7 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Text shared or selected in another app opens a prefilled new
|
<!-- Text shared or selected in another app opens a prefilled new
|
||||||
event (MainActivity.sharedTextExtrasOrNull, issue #306). -->
|
event (MainActivity.sharedTextRequestOrNull, issue #306). -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.SEND" />
|
<action android:name="android.intent.action.SEND" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.content.res.Configuration
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.provider.CalendarContract
|
import android.provider.CalendarContract
|
||||||
|
import android.provider.OpenableColumns
|
||||||
import androidx.activity.SystemBarStyle
|
import androidx.activity.SystemBarStyle
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
@@ -61,6 +62,8 @@ import de.jeanlucmakiola.calendula.ui.theme.WindowThemeCache
|
|||||||
import de.jeanlucmakiola.calendula.ui.theme.windowBackgroundRes
|
import de.jeanlucmakiola.calendula.ui.theme.windowBackgroundRes
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography
|
import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
|
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
@@ -124,10 +127,17 @@ class MainActivity : AppCompatActivity() {
|
|||||||
cachedTheme?.windowBackgroundRes()?.let { window.setBackgroundDrawableResource(it) }
|
cachedTheme?.windowBackgroundRes()?.let { window.setBackgroundDrawableResource(it) }
|
||||||
systemBarsDark = (cachedTheme ?: ThemeMode.SYSTEM).isDark(systemDark)
|
systemBarsDark = (cachedTheme ?: ThemeMode.SYSTEM).isDark(systemDark)
|
||||||
applyEdgeToEdge()
|
applyEdgeToEdge()
|
||||||
|
val appearance = MutableStateFlow(
|
||||||
|
Appearance(
|
||||||
|
themeMode = cachedTheme ?: ThemeMode.SYSTEM,
|
||||||
|
dynamicColor = WindowThemeCache.readDynamicColor(this),
|
||||||
|
),
|
||||||
|
)
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
settingsPrefs.themeMode.collect { mode ->
|
combine(settingsPrefs.themeMode, settingsPrefs.dynamicColor, ::Appearance).collect { stored ->
|
||||||
WindowThemeCache.write(this@MainActivity, mode)
|
WindowThemeCache.write(this@MainActivity, stored.themeMode, stored.dynamicColor ?: true)
|
||||||
window.setBackgroundDrawableResource(mode.windowBackgroundRes() ?: R.color.window_background)
|
window.setBackgroundDrawableResource(stored.themeMode.windowBackgroundRes() ?: R.color.window_background)
|
||||||
|
appearance.value = stored
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Android hands the intent that started the task back to onCreate every
|
// Android hands the intent that started the task back to onCreate every
|
||||||
@@ -143,6 +153,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
// is what carries one through a recreation — the create form only while
|
// is what carries one through a recreation — the create form only while
|
||||||
// it's still open.
|
// it's still open.
|
||||||
val replaying = savedInstanceState != null || intent.isRelaunch()
|
val replaying = savedInstanceState != null || intent.isRelaunch()
|
||||||
|
requests.replayed = savedInstanceState != null
|
||||||
if (!replaying) {
|
if (!replaying) {
|
||||||
requests.detailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
requests.detailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
||||||
requests.editKey = intent.editEventKeyOrNull()
|
requests.editKey = intent.editEventKeyOrNull()
|
||||||
@@ -161,7 +172,11 @@ class MainActivity : AppCompatActivity() {
|
|||||||
// and the Settings screen, so a theme change applies app-wide at once.
|
// and the Settings screen, so a theme change applies app-wide at once.
|
||||||
val settingsViewModel: SettingsViewModel = hiltViewModel()
|
val settingsViewModel: SettingsViewModel = hiltViewModel()
|
||||||
val settings by settingsViewModel.state.collectAsStateWithLifecycle()
|
val settings by settingsViewModel.state.collectAsStateWithLifecycle()
|
||||||
val darkTheme = settings.themeMode.isDark(isSystemInDarkTheme())
|
// Seeded from the cache, so the first frame doesn't draw the default
|
||||||
|
// theme and palette before the stored choices arrive.
|
||||||
|
val shownAppearance by appearance.collectAsStateWithLifecycle()
|
||||||
|
val themeMode = shownAppearance.themeMode
|
||||||
|
val darkTheme = themeMode.isDark(isSystemInDarkTheme())
|
||||||
// onCreate can only see the night resource qualifier, not the in-app
|
// onCreate can only see the night resource qualifier, not the in-app
|
||||||
// override — re-apply from the resolved theme so the bar icons follow
|
// override — re-apply from the resolved theme so the bar icons follow
|
||||||
// the app's light/dark choice.
|
// the app's light/dark choice.
|
||||||
@@ -199,8 +214,8 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
CalendulaTheme(
|
CalendulaTheme(
|
||||||
darkTheme = darkTheme,
|
darkTheme = darkTheme,
|
||||||
dynamicColor = settings.dynamicColor,
|
dynamicColor = shownAppearance.dynamicColor ?: settings.dynamicColor,
|
||||||
pureBlack = settings.themeMode.isPureBlack,
|
pureBlack = themeMode.isPureBlack,
|
||||||
typography = typography,
|
typography = typography,
|
||||||
) {
|
) {
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
@@ -273,6 +288,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
|
requests.replayed = false
|
||||||
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requests.detailKey = it }
|
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requests.detailKey = it }
|
||||||
intent.navRequestOrNull()?.let { requests.nav = it }
|
intent.navRequestOrNull()?.let { requests.nav = it }
|
||||||
intent.importUriOrNull()?.let { requests.importUri = it }
|
intent.importUriOrNull()?.let { requests.importUri = it }
|
||||||
@@ -281,6 +297,13 @@ class MainActivity : AppCompatActivity() {
|
|||||||
insertFormOpen = true
|
insertFormOpen = true
|
||||||
}
|
}
|
||||||
intent.editEventKeyOrNull()?.let { requests.editKey = it }
|
intent.editEventKeyOrNull()?.let { requests.editKey = it }
|
||||||
|
// The user moved on into the app, so closing the create form no longer
|
||||||
|
// hands control back to the app that started it.
|
||||||
|
if (requests.detailKey != null || requests.nav != null || requests.importUri != null ||
|
||||||
|
requests.editKey != null
|
||||||
|
) {
|
||||||
|
launchedForInsert = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -301,9 +324,10 @@ class MainActivity : AppCompatActivity() {
|
|||||||
private fun Intent.importUriOrNull(): Uri? {
|
private fun Intent.importUriOrNull(): Uri? {
|
||||||
val uri = when (action) {
|
val uri = when (action) {
|
||||||
Intent.ACTION_VIEW -> data
|
Intent.ACTION_VIEW -> data
|
||||||
// Plain text is a create (#306), never a file to import.
|
// Shared text is a create (#306); a text/plain file is imported only
|
||||||
Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
|
// when it is calendar data, e.g. an .ics attachment labelled text/plain.
|
||||||
.takeUnless { type == SHARED_TEXT_TYPE }
|
Intent.ACTION_SEND -> sharedStreamOrNull()
|
||||||
|
?.takeUnless { type == SHARED_TEXT_TYPE && !it.isCalendarFile() }
|
||||||
else -> null
|
else -> null
|
||||||
} ?: return null
|
} ?: return null
|
||||||
// The calendar "view time" Uri (a date tap) is also ACTION_VIEW/content;
|
// The calendar "view time" Uri (a date tap) is also ACTION_VIEW/content;
|
||||||
@@ -357,7 +381,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
*/
|
*/
|
||||||
private fun Intent.sharedTextRequestOrNull(): InsertRequest? {
|
private fun Intent.sharedTextRequestOrNull(): InsertRequest? {
|
||||||
val shared = when (action) {
|
val shared = when (action) {
|
||||||
Intent.ACTION_SEND -> if (type == SHARED_TEXT_TYPE) {
|
Intent.ACTION_SEND -> if (type == SHARED_TEXT_TYPE && sharedStreamOrNull()?.isCalendarFile() != true) {
|
||||||
sharedTextOrNull(
|
sharedTextOrNull(
|
||||||
subject = getCharSequenceExtra(Intent.EXTRA_SUBJECT)?.toString(),
|
subject = getCharSequenceExtra(Intent.EXTRA_SUBJECT)?.toString(),
|
||||||
text = getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString(),
|
text = getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString(),
|
||||||
@@ -378,6 +402,23 @@ class MainActivity : AppCompatActivity() {
|
|||||||
return InsertRequest(form, ImportSource.InsertOpenEnded)
|
return InsertRequest(form, ImportSource.InsertOpenEnded)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Intent.sharedStreamOrNull(): Uri? =
|
||||||
|
IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
|
||||||
|
|
||||||
|
/** Whether a shared file is iCalendar/vCalendar data, by its resolved type or its name. */
|
||||||
|
private fun Uri.isCalendarFile(): Boolean {
|
||||||
|
val resolvedType = runCatching { contentResolver.getType(this) }.getOrNull()
|
||||||
|
if (resolvedType in CALENDAR_TYPES) return true
|
||||||
|
val name = when (scheme) {
|
||||||
|
"content" -> runCatching {
|
||||||
|
contentResolver.query(this, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
|
||||||
|
?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
|
||||||
|
}.getOrNull()
|
||||||
|
else -> lastPathSegment
|
||||||
|
} ?: return false
|
||||||
|
return CALENDAR_EXTENSIONS.any { name.endsWith(it, ignoreCase = true) }
|
||||||
|
}
|
||||||
|
|
||||||
/** A Long extra's value, or null when the extra is absent. */
|
/** A Long extra's value, or null when the extra is absent. */
|
||||||
private fun Intent.longExtraOrNull(key: String): Long? =
|
private fun Intent.longExtraOrNull(key: String): Long? =
|
||||||
if (hasExtra(key)) getLongExtra(key, 0L) else null
|
if (hasExtra(key)) getLongExtra(key, 0L) else null
|
||||||
@@ -504,6 +545,8 @@ class MainActivity : AppCompatActivity() {
|
|||||||
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
|
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
|
||||||
private const val CALENDAR_PROVIDER_HOST = "com.android.calendar"
|
private const val CALENDAR_PROVIDER_HOST = "com.android.calendar"
|
||||||
private const val SHARED_TEXT_TYPE = "text/plain"
|
private const val SHARED_TEXT_TYPE = "text/plain"
|
||||||
|
private val CALENDAR_TYPES = setOf("text/calendar", "text/x-vcalendar", "application/ics")
|
||||||
|
private val CALENDAR_EXTENSIONS = listOf(".ics", ".vcs")
|
||||||
private const val KEY_INSERT_FORM_OPEN = "insert_form_open"
|
private const val KEY_INSERT_FORM_OPEN = "insert_form_open"
|
||||||
private const val KEY_LAUNCHED_FOR_INSERT = "launched_for_insert"
|
private const val KEY_LAUNCHED_FOR_INSERT = "launched_for_insert"
|
||||||
|
|
||||||
@@ -596,3 +639,6 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The theme choices the window needs before the settings screen's state loads. */
|
||||||
|
private data class Appearance(val themeMode: ThemeMode, val dynamicColor: Boolean?)
|
||||||
|
|||||||
@@ -725,7 +725,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
),
|
),
|
||||||
null,
|
null,
|
||||||
)?.use { c ->
|
)?.use { c ->
|
||||||
c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) }
|
c.mapRows { EventColorOption(key = getString(0).orEmpty(), argb = getInt(1)) }
|
||||||
}
|
}
|
||||||
?.filter { it.key.isNotEmpty() }
|
?.filter { it.key.isNotEmpty() }
|
||||||
?: emptyList()
|
?: emptyList()
|
||||||
@@ -1116,7 +1116,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
?: throw WriteFailedException("read modified occurrence id=${ex.exceptionEventId}")
|
?: throw WriteFailedException("read modified occurrence id=${ex.exceptionEventId}")
|
||||||
val endMillis = ex.dtEndMillis ?: (ex.dtStartMillis + parseRfc2445DurationMillis(ex.duration))
|
val endMillis = ex.dtEndMillis ?: (ex.dtStartMillis + parseRfc2445DurationMillis(ex.duration))
|
||||||
val form = detail.toEditForm(ex.dtStartMillis, endMillis, zone)
|
val form = detail.toEditForm(ex.dtStartMillis, endMillis, zone)
|
||||||
.copy(calendarId = targetCalendarId)
|
.copy(calendarId = targetCalendarId, colorKey = null)
|
||||||
.toDetachedOccurrence()
|
.toDetachedOccurrence()
|
||||||
detached += insertEvent(form, allDayReminderTimeMinutes)
|
detached += insertEvent(form, allDayReminderTimeMinutes)
|
||||||
}
|
}
|
||||||
@@ -1691,11 +1691,6 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
null,
|
null,
|
||||||
)?.use { c -> c.mapRows { toReminder() } } ?: emptyList()
|
)?.use { c -> c.mapRows { toReminder() } } ?: emptyList()
|
||||||
|
|
||||||
/** Iterate every row and map; skips nothing. */
|
|
||||||
private inline fun <T> Cursor.mapAll(mapper: (Cursor) -> T): List<T> = buildList {
|
|
||||||
while (moveToNext()) add(mapper(this@mapAll))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A fresh globally-unique `UID_2445`, for an event that carries none of its own. */
|
/** A fresh globally-unique `UID_2445`, for an event that carries none of its own. */
|
||||||
private fun mintUid(): String = "${UUID.randomUUID()}@calendula"
|
private fun mintUid(): String = "${UUID.randomUUID()}@calendula"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.di
|
||||||
|
|
||||||
|
import javax.inject.Qualifier
|
||||||
|
|
||||||
|
/** The process-wide `CoroutineScope` that app-lifetime singletons share their flows in. */
|
||||||
|
@Qualifier
|
||||||
|
@Retention(AnnotationRetention.BINARY)
|
||||||
|
annotation class ApplicationScope
|
||||||
@@ -24,6 +24,9 @@ import de.jeanlucmakiola.calendula.data.time.AndroidTodaySource
|
|||||||
import de.jeanlucmakiola.calendula.data.time.AndroidZoneSource
|
import de.jeanlucmakiola.calendula.data.time.AndroidZoneSource
|
||||||
import de.jeanlucmakiola.calendula.data.time.TodaySource
|
import de.jeanlucmakiola.calendula.data.time.TodaySource
|
||||||
import de.jeanlucmakiola.calendula.data.time.ZoneSource
|
import de.jeanlucmakiola.calendula.data.time.ZoneSource
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
private val Context.calendulaDataStore: DataStore<Preferences> by preferencesDataStore(
|
private val Context.calendulaDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||||
@@ -85,4 +88,9 @@ object DataProvideModule {
|
|||||||
@Singleton
|
@Singleton
|
||||||
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||||
context.calendulaDataStore
|
context.calendulaDataStore
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
@ApplicationScope
|
||||||
|
fun provideApplicationScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ package de.jeanlucmakiola.calendula.data.time
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.calendula.data.di.ApplicationScope
|
||||||
import de.jeanlucmakiola.floret.android.broadcastTicks
|
import de.jeanlucmakiola.floret.android.broadcastTicks
|
||||||
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.shareIn
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
@@ -27,10 +30,12 @@ interface TodaySource {
|
|||||||
/**
|
/**
|
||||||
* Re-reads the date on every minute boundary while collected, and at once on the
|
* Re-reads the date on every minute boundary while collected, and at once on the
|
||||||
* system's date, time and zone broadcasts, which can arrive late or not at all.
|
* system's date, time and zone broadcasts, which can arrive late or not at all.
|
||||||
|
* One receiver and tick loop is shared by every collector.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class AndroidTodaySource @Inject constructor(
|
class AndroidTodaySource @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
|
@ApplicationScope scope: CoroutineScope,
|
||||||
) : TodaySource {
|
) : TodaySource {
|
||||||
|
|
||||||
override val today: Flow<LocalDate> = context
|
override val today: Flow<LocalDate> = context
|
||||||
@@ -42,6 +47,7 @@ class AndroidTodaySource @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
|
.shareIn(scope, SharingStarted.WhileSubscribed(replayExpirationMillis = 0), replay = 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val MINUTE_MILLIS = 60_000L
|
private const val MINUTE_MILLIS = 60_000L
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ package de.jeanlucmakiola.calendula.data.time
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.calendula.data.di.ApplicationScope
|
||||||
import de.jeanlucmakiola.floret.android.broadcastTicks
|
import de.jeanlucmakiola.floret.android.broadcastTicks
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.shareIn
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -23,14 +27,16 @@ interface ZoneSource {
|
|||||||
fun current(): TimeZone
|
fun current(): TimeZone
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Follows the system's zone broadcast. */
|
/** Follows the system's zone broadcast, one receiver shared by every collector. */
|
||||||
class AndroidZoneSource @Inject constructor(
|
class AndroidZoneSource @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
|
@ApplicationScope scope: CoroutineScope,
|
||||||
) : ZoneSource {
|
) : ZoneSource {
|
||||||
|
|
||||||
override val zone: Flow<TimeZone> = context.broadcastTicks(Intent.ACTION_TIMEZONE_CHANGED)
|
override val zone: Flow<TimeZone> = context.broadcastTicks(Intent.ACTION_TIMEZONE_CHANGED)
|
||||||
.map { current() }
|
.map { current() }
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
|
.shareIn(scope, SharingStarted.WhileSubscribed(replayExpirationMillis = 0), replay = 1)
|
||||||
|
|
||||||
override fun current(): TimeZone = TimeZone.currentSystemDefault()
|
override fun current(): TimeZone = TimeZone.currentSystemDefault()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,6 +131,10 @@ private class HtmlRenderer(private val html: String) {
|
|||||||
text("<")
|
text("<")
|
||||||
return lt + 1
|
return lt + 1
|
||||||
}
|
}
|
||||||
|
if (!closing && isAutolink(html.substring(nameStart, end))) {
|
||||||
|
text(html.substring(lt, end + 1))
|
||||||
|
return end + 1
|
||||||
|
}
|
||||||
var nameEnd = nameStart
|
var nameEnd = nameStart
|
||||||
while (nameEnd < end && (html[nameEnd].isLetterOrDigit() || html[nameEnd] == ':')) nameEnd++
|
while (nameEnd < end && (html[nameEnd].isLetterOrDigit() || html[nameEnd] == ':')) nameEnd++
|
||||||
val name = html.substring(nameStart, nameEnd).lowercase()
|
val name = html.substring(nameStart, nameEnd).lowercase()
|
||||||
@@ -149,6 +153,11 @@ private class HtmlRenderer(private val html: String) {
|
|||||||
return end + 1
|
return end + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** An angle-bracketed URL or address (`<https://…>`, `<a@b.c>`), kept as text. */
|
||||||
|
private fun isAutolink(inner: String): Boolean =
|
||||||
|
LINK_SCHEMES.any { inner.startsWith(it, ignoreCase = true) } ||
|
||||||
|
('@' in inner && inner.none { it.isWhitespace() || it == '=' })
|
||||||
|
|
||||||
/** The `>` closing the tag at [lt], skipping any inside quoted attributes. */
|
/** The `>` closing the tag at [lt], skipping any inside quoted attributes. */
|
||||||
private fun tagEnd(lt: Int): Int {
|
private fun tagEnd(lt: Int): Int {
|
||||||
var quote: Char? = null
|
var quote: Char? = null
|
||||||
|
|||||||
@@ -66,7 +66,9 @@ data class SanitizedRrule(val rule: String?, val repaired: Boolean)
|
|||||||
* rule at all — gives up entirely.
|
* rule at all — gives up entirely.
|
||||||
*/
|
*/
|
||||||
fun sanitizeRrule(raw: String?): SanitizedRrule {
|
fun sanitizeRrule(raw: String?): SanitizedRrule {
|
||||||
val rule = raw?.trim()?.removePrefix("RRULE:")?.trim().orEmpty()
|
val rule = raw?.trim()
|
||||||
|
?.let { if (it.startsWith("RRULE:", ignoreCase = true)) it.drop("RRULE:".length) else it }
|
||||||
|
?.trim().orEmpty()
|
||||||
if (rule.isEmpty()) return SanitizedRrule(rule = null, repaired = false)
|
if (rule.isEmpty()) return SanitizedRrule(rule = null, repaired = false)
|
||||||
|
|
||||||
var freqSeen = false
|
var freqSeen = false
|
||||||
@@ -84,8 +86,8 @@ fun sanitizeRrule(raw: String?): SanitizedRrule {
|
|||||||
// A stray separator ("FREQ=DAILY;") normalises away; nothing was lost.
|
// A stray separator ("FREQ=DAILY;") normalises away; nothing was lost.
|
||||||
// A part that carries text but no '=' ("FREQ=WEEKLY;BYDAY") is a part
|
// A part that carries text but no '=' ("FREQ=WEEKLY;BYDAY") is a part
|
||||||
// being dropped, so it falls through and marks the rule repaired.
|
// being dropped, so it falls through and marks the rule repaired.
|
||||||
for (part in rruleParts(raw.orEmpty().trim())) {
|
for (part in rruleParts(rule)) {
|
||||||
val key = part.key?.trim().orEmpty()
|
val key = part.key?.trim()?.uppercase().orEmpty()
|
||||||
val value = part.value.trim()
|
val value = part.value.trim()
|
||||||
val kept = when {
|
val kept = when {
|
||||||
key.isEmpty() || value.isEmpty() -> null
|
key.isEmpty() || value.isEmpty() -> null
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import androidx.compose.animation.AnimatedVisibility
|
|||||||
import androidx.compose.animation.AnimatedVisibilityScope
|
import androidx.compose.animation.AnimatedVisibilityScope
|
||||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
import androidx.compose.animation.SharedTransitionLayout
|
import androidx.compose.animation.SharedTransitionLayout
|
||||||
|
import androidx.compose.animation.core.MutableTransitionState
|
||||||
import androidx.compose.animation.core.SeekableTransitionState
|
import androidx.compose.animation.core.SeekableTransitionState
|
||||||
import androidx.compose.animation.core.rememberTransition
|
import androidx.compose.animation.core.rememberTransition
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
@@ -34,6 +35,7 @@ import androidx.compose.runtime.saveable.listSaver
|
|||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberHeld
|
||||||
import de.jeanlucmakiola.calendula.ui.common.screen
|
import de.jeanlucmakiola.calendula.ui.common.screen
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewScreen
|
import de.jeanlucmakiola.calendula.ui.common.ViewScreen
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
@@ -66,7 +68,7 @@ import de.jeanlucmakiola.calendula.ui.common.EventSelectionHost
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.eventActionFailureMessage
|
import de.jeanlucmakiola.calendula.ui.common.eventActionFailureMessage
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberFabSelection
|
import de.jeanlucmakiola.calendula.ui.common.rememberFabSelection
|
||||||
import de.jeanlucmakiola.calendula.ui.common.canWriteCalendar
|
import de.jeanlucmakiola.calendula.ui.common.canWriteCalendar
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGateOr
|
import de.jeanlucmakiola.calendula.ui.common.rememberWriteAction
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
|
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
|
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
|
||||||
@@ -243,20 +245,29 @@ fun CalendarHost(
|
|||||||
// An opened/received .ics file. [ImportScreen] parses it and either opens
|
// An opened/received .ics file. [ImportScreen] parses it and either opens
|
||||||
// the prefilled create form (one event → [importForm]) or its own bulk
|
// the prefilled create form (one event → [importForm]) or its own bulk
|
||||||
// picker (many). A plain conditional overlay (no slide) — it's transient.
|
// picker (many). A plain conditional overlay (no slide) — it's transient.
|
||||||
var importUri by remember { mutableStateOf<android.net.Uri?>(null) }
|
var importUri by rememberSaveable { mutableStateOf<android.net.Uri?>(null) }
|
||||||
var importForm by remember { mutableStateOf<EventForm?>(null) }
|
val importViewModel: EventEditViewModel = hiltViewModel(key = "import")
|
||||||
|
// Saved as "open"; a recreation takes the form back from the ViewModel, which
|
||||||
|
// still holds the user's edits.
|
||||||
|
var importForm by rememberSaveable(
|
||||||
|
stateSaver = Saver<EventForm?, Boolean>(
|
||||||
|
save = { it != null },
|
||||||
|
restore = { open -> if (open) importViewModel.currentForm() else null },
|
||||||
|
),
|
||||||
|
) { mutableStateOf<EventForm?>(null) }
|
||||||
// Which channel filled [importForm]: an .ics file (prompt to apply the default
|
// Which channel filled [importForm]: an .ics file (prompt to apply the default
|
||||||
// reminder) or an ACTION_INSERT intent (apply it automatically) — #49.
|
// reminder) or an ACTION_INSERT intent (apply it automatically) — #49.
|
||||||
var importFormSource by remember { mutableStateOf(ImportSource.File) }
|
var importFormSource by rememberSaveable { mutableStateOf(ImportSource.File) }
|
||||||
// Bumped per open, so even an equal prefill starts a fresh form.
|
// Bumped per open, so even an equal prefill starts a fresh form.
|
||||||
var importFormId by remember { mutableIntStateOf(0) }
|
var importFormId by rememberSaveable { mutableIntStateOf(0) }
|
||||||
// Whether [importForm] came from an external create request (#30, #306);
|
// Whether [importForm] came from an external create request (#30, #306);
|
||||||
// closing it then hands control back to whoever asked.
|
// closing it then hands control back to whoever asked.
|
||||||
var importFromRequest by rememberSaveable { mutableStateOf(false) }
|
var importFromRequest by rememberSaveable { mutableStateOf(false) }
|
||||||
// A second external request that arrived while a prefilled form was open:
|
// A second external request that arrived while a prefilled form was open:
|
||||||
// it waits behind that form's unsaved-changes prompt, then opens.
|
// it waits behind that form's unsaved-changes prompt, then opens.
|
||||||
var pendingInsert by remember { mutableStateOf<Pair<EventForm, ImportSource>?>(null) }
|
var pendingInsert by remember { mutableStateOf<Pair<EventForm, ImportSource>?>(null) }
|
||||||
val importViewModel: EventEditViewModel = hiltViewModel(key = "import")
|
val createViewModel: EventEditViewModel = hiltViewModel(key = "create")
|
||||||
|
val editViewModel: EventEditViewModel = hiltViewModel(key = "edit")
|
||||||
fun openImportForm(form: EventForm, source: ImportSource, fromRequest: Boolean = false) {
|
fun openImportForm(form: EventForm, source: ImportSource, fromRequest: Boolean = false) {
|
||||||
importFormSource = source
|
importFormSource = source
|
||||||
importForm = form
|
importForm = form
|
||||||
@@ -280,7 +291,7 @@ fun CalendarHost(
|
|||||||
// flow — picker + summary — even for a single-event file, because the intent
|
// flow — picker + summary — even for a single-event file, because the intent
|
||||||
// is "restore a backup", not "add this one event". An externally opened .ics
|
// is "restore a backup", not "add this one event". An externally opened .ics
|
||||||
// keeps routing a single event straight into the prefilled create form.
|
// keeps routing a single event straight into the prefilled create form.
|
||||||
var importForceMany by remember { mutableStateOf(false) }
|
var importForceMany by rememberSaveable { mutableStateOf(false) }
|
||||||
// Where a restore came from, so closing the import puts it back: the import
|
// Where a restore came from, so closing the import puts it back: the import
|
||||||
// overlays are declared under Backup & restore and the manager, so starting
|
// overlays are declared under Backup & restore and the manager, so starting
|
||||||
// one has to close them — without this, finishing a restore drops you on the
|
// one has to close them — without this, finishing a restore drops you on the
|
||||||
@@ -291,9 +302,20 @@ fun CalendarHost(
|
|||||||
// what tells it a *re-import of the same file* is new work and not the run
|
// what tells it a *re-import of the same file* is new work and not the run
|
||||||
// it already finished; a rotation keeps the number and keeps the result.
|
// it already finished; a rotation keeps the number and keeps the result.
|
||||||
var importSession by rememberSaveable { mutableStateOf(0) }
|
var importSession by rememberSaveable { mutableStateOf(0) }
|
||||||
|
// A recreation re-reads the launch intent; when the import it carries came
|
||||||
|
// back with the saved state, that replay is the same work, not a new one.
|
||||||
|
fun isReplayOfRestoredImport(): Boolean {
|
||||||
|
val replay = requests.replayed && (importUri != null || importForm != null)
|
||||||
|
requests.replayed = false
|
||||||
|
return replay
|
||||||
|
}
|
||||||
val requestedImportUri = requests.importUri
|
val requestedImportUri = requests.importUri
|
||||||
LaunchedEffect(requestedImportUri) {
|
LaunchedEffect(requestedImportUri) {
|
||||||
if (requestedImportUri != null) {
|
if (requestedImportUri != null) {
|
||||||
|
if (isReplayOfRestoredImport()) {
|
||||||
|
requests.importUri = null
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
importUri = requestedImportUri
|
importUri = requestedImportUri
|
||||||
importForceMany = false
|
importForceMany = false
|
||||||
importSession++
|
importSession++
|
||||||
@@ -307,6 +329,10 @@ fun CalendarHost(
|
|||||||
val requestedInsert = requests.insert
|
val requestedInsert = requests.insert
|
||||||
LaunchedEffect(requestedInsert) {
|
LaunchedEffect(requestedInsert) {
|
||||||
if (requestedInsert != null) {
|
if (requestedInsert != null) {
|
||||||
|
if (isReplayOfRestoredImport()) {
|
||||||
|
requests.insert = null
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
if (importForm == null) {
|
if (importForm == null) {
|
||||||
openImportForm(requestedInsert.form, requestedInsert.source, fromRequest = true)
|
openImportForm(requestedInsert.form, requestedInsert.source, fromRequest = true)
|
||||||
} else {
|
} else {
|
||||||
@@ -318,16 +344,22 @@ fun CalendarHost(
|
|||||||
|
|
||||||
// Close every overlay that can sit over the calendar, so an externally
|
// Close every overlay that can sit over the calendar, so an externally
|
||||||
// requested destination (a widget/shortcut/QS-tile launch) is revealed on
|
// requested destination (a widget/shortcut/QS-tile launch) is revealed on
|
||||||
// top instead of underneath whatever the user had open.
|
// top instead of underneath whatever the user had open. The create form,
|
||||||
fun dismissCoveringOverlays() {
|
// declared under the edit form, can stay for an edit to open over it.
|
||||||
|
fun dismissCoveringOverlays(keepCreate: Boolean = false) {
|
||||||
showSettings = false
|
showSettings = false
|
||||||
|
showSearch = false
|
||||||
showCalendars = false
|
showCalendars = false
|
||||||
showBackup = false
|
showBackup = false
|
||||||
detailKey = null
|
detailKey = null
|
||||||
editKey = null
|
editKey = null
|
||||||
|
if (!keepCreate) createRequest = null
|
||||||
importUri = null
|
importUri = null
|
||||||
importForm = null
|
importForm = null
|
||||||
importFromRequest = false
|
if (importFromRequest) {
|
||||||
|
importFromRequest = false
|
||||||
|
requests.onExternalFormClosed()
|
||||||
|
}
|
||||||
pendingInsert = null
|
pendingInsert = null
|
||||||
importViewModel.reset()
|
importViewModel.reset()
|
||||||
}
|
}
|
||||||
@@ -340,7 +372,10 @@ fun CalendarHost(
|
|||||||
val requestedEditKey = requests.editKey
|
val requestedEditKey = requests.editKey
|
||||||
LaunchedEffect(requestedEditKey) {
|
LaunchedEffect(requestedEditKey) {
|
||||||
if (requestedEditKey != null) {
|
if (requestedEditKey != null) {
|
||||||
dismissCoveringOverlays()
|
// An open edit form stays on screen, so it starts over here rather
|
||||||
|
// than once it has slid away.
|
||||||
|
if (editKey != null) editViewModel.reset()
|
||||||
|
dismissCoveringOverlays(keepCreate = true)
|
||||||
editKey = requestedEditKey
|
editKey = requestedEditKey
|
||||||
requests.editKey = null
|
requests.editKey = null
|
||||||
}
|
}
|
||||||
@@ -361,7 +396,6 @@ fun CalendarHost(
|
|||||||
// Drill the day view in over the widget's view: drop any overlay
|
// Drill the day view in over the widget's view: drop any overlay
|
||||||
// that would cover it, so the open doesn't land under Settings/form.
|
// that would cover it, so the open doesn't land under Settings/form.
|
||||||
dismissCoveringOverlays()
|
dismissCoveringOverlays()
|
||||||
createRequest = null
|
|
||||||
focus.navigateTo(LocalDate.parse(req.dateIso))
|
focus.navigateTo(LocalDate.parse(req.dateIso))
|
||||||
// No widget source (an external date tap) roots over the default
|
// No widget source (an external date tap) roots over the default
|
||||||
// home view, so backing out of the day returns home then exits.
|
// home view, so backing out of the day returns home then exits.
|
||||||
@@ -372,7 +406,6 @@ fun CalendarHost(
|
|||||||
// Root the stack in the widget's view, then open the occurrence
|
// Root the stack in the widget's view, then open the occurrence
|
||||||
// detail over it (same key shape as a tapped event / reminder).
|
// detail over it (same key shape as a tapped event / reminder).
|
||||||
dismissCoveringOverlays()
|
dismissCoveringOverlays()
|
||||||
createRequest = null
|
|
||||||
viewStack = viewBaseStack(defaultView, req.source)
|
viewStack = viewBaseStack(defaultView, req.source)
|
||||||
detailKey = longArrayOf(req.eventId, req.beginMillis, req.endMillis)
|
detailKey = longArrayOf(req.eventId, req.beginMillis, req.endMillis)
|
||||||
requests.nav = null
|
requests.nav = null
|
||||||
@@ -383,7 +416,6 @@ fun CalendarHost(
|
|||||||
// the stack on the target (null → the default home) over the
|
// the stack on the target (null → the default home) over the
|
||||||
// default home — so backing out returns to the default, then exits.
|
// default home — so backing out returns to the default, then exits.
|
||||||
dismissCoveringOverlays()
|
dismissCoveringOverlays()
|
||||||
createRequest = null
|
|
||||||
focus.navigateTo(currentDate())
|
focus.navigateTo(currentDate())
|
||||||
viewStack = viewBaseStack(defaultView, req.view ?: defaultView)
|
viewStack = viewBaseStack(defaultView, req.view ?: defaultView)
|
||||||
requests.nav = null
|
requests.nav = null
|
||||||
@@ -393,6 +425,7 @@ fun CalendarHost(
|
|||||||
// widget) must land on top of whatever is open — the form overlay
|
// widget) must land on top of whatever is open — the form overlay
|
||||||
// sits below Settings/calendars in the Box, so without this it
|
// sits below Settings/calendars in the Box, so without this it
|
||||||
// would open hidden underneath them.
|
// would open hidden underneath them.
|
||||||
|
if (createRequest != null) createViewModel.reset()
|
||||||
dismissCoveringOverlays()
|
dismissCoveringOverlays()
|
||||||
openCreate(req.dateIso ?: currentDate().toString())
|
openCreate(req.dateIso ?: currentDate().toString())
|
||||||
requests.nav = null
|
requests.nav = null
|
||||||
@@ -414,9 +447,22 @@ fun CalendarHost(
|
|||||||
// tap time, so a tap before they've loaded still routes right.
|
// tap time, so a tap before they've loaded still routes right.
|
||||||
val tapScope = rememberCoroutineScope()
|
val tapScope = rememberCoroutineScope()
|
||||||
val tapContext = LocalContext.current
|
val tapContext = LocalContext.current
|
||||||
val requireWriteOr = rememberWriteGateOr()
|
|
||||||
// A refused write upgrade is asked once; later taps just open the details.
|
// A refused write upgrade is asked once; later taps just open the details.
|
||||||
var tapWriteRefused by rememberSaveable { mutableStateOf(false) }
|
var tapWriteRefused by rememberSaveable { mutableStateOf(false) }
|
||||||
|
// The tap waiting on the WRITE_CALENDAR prompt, saved so a rotation keeps it.
|
||||||
|
var tapAwaitingWrite by rememberSaveable { mutableStateOf<LongArray?>(null) }
|
||||||
|
val editAwaitingTap = rememberWriteAction(
|
||||||
|
onDenied = {
|
||||||
|
tapAwaitingWrite?.let { key ->
|
||||||
|
tapWriteRefused = true
|
||||||
|
detailKey = key
|
||||||
|
}
|
||||||
|
tapAwaitingWrite = null
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
tapAwaitingWrite?.let { editKey = it }
|
||||||
|
tapAwaitingWrite = null
|
||||||
|
}
|
||||||
val onEventClick: (EventInstance) -> Unit = { event ->
|
val onEventClick: (EventInstance) -> Unit = { event ->
|
||||||
tapScope.launch {
|
tapScope.launch {
|
||||||
val toEditor = viewModel.tapOpensEditor() && eventActions.canModify(event.calendarId)
|
val toEditor = viewModel.tapOpensEditor() && eventActions.canModify(event.calendarId)
|
||||||
@@ -424,9 +470,9 @@ fun CalendarHost(
|
|||||||
!toEditor -> onOpenDetail(event)
|
!toEditor -> onOpenDetail(event)
|
||||||
tapContext.canWriteCalendar() -> onEditEvent(event)
|
tapContext.canWriteCalendar() -> onEditEvent(event)
|
||||||
tapWriteRefused -> onOpenDetail(event)
|
tapWriteRefused -> onOpenDetail(event)
|
||||||
else -> requireWriteOr({ onEditEvent(event) }) {
|
else -> {
|
||||||
tapWriteRefused = true
|
tapAwaitingWrite = event.occurrenceKey()
|
||||||
onOpenDetail(event)
|
editAwaitingTap()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -674,24 +720,26 @@ fun CalendarHost(
|
|||||||
|
|
||||||
// Event form (v1.2) — full-screen destination, slides over the calendar.
|
// Event form (v1.2) — full-screen destination, slides over the calendar.
|
||||||
val shownCreate = rememberHeld(createRequest)
|
val shownCreate = rememberHeld(createRequest)
|
||||||
SlideOverlay(visible = createRequest != null) {
|
SlideOverlay(visible = createRequest != null, onHidden = createViewModel::reset) {
|
||||||
shownCreate?.let { create ->
|
shownCreate?.let { create ->
|
||||||
EventEditScreen(
|
key(create) {
|
||||||
initialDateIso = create.dateIso,
|
EventEditScreen(
|
||||||
initialStartMinutes = create.startMinutes,
|
initialDateIso = create.dateIso,
|
||||||
initialDraft = create.draftMillis,
|
initialStartMinutes = create.startMinutes,
|
||||||
onClose = { createRequest = null },
|
initialDraft = create.draftMillis,
|
||||||
onSaved = { createRequest = null },
|
onClose = { createRequest = null },
|
||||||
onManageCalendars = { showCalendars = true },
|
onSaved = { createRequest = null },
|
||||||
visible = createRequest != null,
|
onManageCalendars = { showCalendars = true },
|
||||||
viewModel = hiltViewModel(key = "create"),
|
visible = createRequest != null,
|
||||||
)
|
viewModel = createViewModel,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edit form (v1.3) — slides over the detail screen.
|
// Edit form (v1.3) — slides over the detail screen.
|
||||||
val shownEditKey = rememberHeld(editKey)
|
val shownEditKey = rememberHeld(editKey)
|
||||||
SlideOverlay(visible = editKey != null) {
|
SlideOverlay(visible = editKey != null, onHidden = editViewModel::reset) {
|
||||||
shownEditKey?.let { key ->
|
shownEditKey?.let { key ->
|
||||||
EventEditScreen(
|
EventEditScreen(
|
||||||
initialDateIso = null,
|
initialDateIso = null,
|
||||||
@@ -712,7 +760,7 @@ fun CalendarHost(
|
|||||||
if (detailKey == null) detailKey = key
|
if (detailKey == null) detailKey = key
|
||||||
},
|
},
|
||||||
visible = editKey != null,
|
visible = editKey != null,
|
||||||
viewModel = hiltViewModel(key = "edit"),
|
viewModel = editViewModel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -830,33 +878,25 @@ private val viewStackSaver = listSaver<List<CalendarView>, Int>(
|
|||||||
* snapping back and sliding off.
|
* snapping back and sliding off.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun SlideOverlay(visible: Boolean, content: @Composable AnimatedVisibilityScope.() -> Unit) {
|
private fun SlideOverlay(
|
||||||
|
visible: Boolean,
|
||||||
|
onHidden: () -> Unit = {},
|
||||||
|
content: @Composable AnimatedVisibilityScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
val state = remember { MutableTransitionState(visible) }
|
||||||
|
state.targetState = visible
|
||||||
|
val hidden = state.isIdle && !state.currentState
|
||||||
|
LaunchedEffect(hidden) {
|
||||||
|
if (hidden) onHidden()
|
||||||
|
}
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visibleState = state,
|
||||||
enter = slideInHorizontally(rememberCalendarSlideSpec()) { it } + fadeIn(),
|
enter = slideInHorizontally(rememberCalendarSlideSpec()) { it } + fadeIn(),
|
||||||
exit = predictiveBackExit(),
|
exit = predictiveBackExit(),
|
||||||
content = content,
|
content = content,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* [live], or after it goes null the last value it had, so an overlay keeps
|
|
||||||
* drawing what it showed through its slide-out instead of flashing empty. Never
|
|
||||||
* a placeholder: until [live] is first set this is null, so a destination never
|
|
||||||
* loads a bogus key on its first frame. Not saved — after a recreation the
|
|
||||||
* saveable [live] is the value again.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun <T : Any> rememberHeld(live: T?): T? {
|
|
||||||
val held = remember { HeldValue<T>() }
|
|
||||||
if (live != null) held.value = live
|
|
||||||
return live ?: held.value
|
|
||||||
}
|
|
||||||
|
|
||||||
private class HeldValue<T : Any> {
|
|
||||||
var value: T? = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The detail and edit overlays' key for one occurrence: eventId, begin, end. */
|
/** The detail and edit overlays' key for one occurrence: eventId, begin, end. */
|
||||||
private fun EventInstance.occurrenceKey(): LongArray =
|
private fun EventInstance.occurrenceKey(): LongArray =
|
||||||
longArrayOf(eventId, start.toEpochMilliseconds(), end.toEpochMilliseconds())
|
longArrayOf(eventId, start.toEpochMilliseconds(), end.toEpochMilliseconds())
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ class ExternalRequests(val onExternalFormClosed: () -> Unit = {}) {
|
|||||||
|
|
||||||
/** An external "edit this event" (ACTION_EDIT), in the detail key's shape. */
|
/** An external "edit this event" (ACTION_EDIT), in the detail key's shape. */
|
||||||
var editKey by mutableStateOf<LongArray?>(null)
|
var editKey by mutableStateOf<LongArray?>(null)
|
||||||
|
|
||||||
|
/** Whether these came from re-reading the launch intent on a recreation. */
|
||||||
|
var replayed = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A prefilled create form from an external launch, with the source it came from. */
|
/** A prefilled create form from an external launch, with the source it came from. */
|
||||||
|
|||||||
@@ -393,11 +393,8 @@ private fun AgendaList(
|
|||||||
val itemDates = agendaItemDates(days)
|
val itemDates = agendaItemDates(days)
|
||||||
val anchorRow = itemDates.indexOfFirst { it >= anchor }.coerceAtLeast(0)
|
val anchorRow = itemDates.indexOfFirst { it >= anchor }.coerceAtLeast(0)
|
||||||
SideEffect { anchorIndex.intValue = anchorRow }
|
SideEffect { anchorIndex.intValue = anchorRow }
|
||||||
// A new anchor opens the list on its row from the first frame, with past
|
// A new anchor opens on its row from the first frame; keyed on the anchor
|
||||||
// days above. Keyed on the anchor the days arrived with, not the one just
|
// the days arrived with, not the one requested. The first run may restore.
|
||||||
// requested: scrolling before the new rows compose leaves the list keyed to
|
|
||||||
// a row that reappears further down, and it follows it there. The first run
|
|
||||||
// may restore a remembered day instead.
|
|
||||||
val pendingRestore = remember { arrayOf(initialTopDate) }
|
val pendingRestore = remember { arrayOf(initialTopDate) }
|
||||||
DisposableEffect(anchor) {
|
DisposableEffect(anchor) {
|
||||||
val target = pendingRestore[0]
|
val target = pendingRestore[0]
|
||||||
|
|||||||
@@ -163,15 +163,17 @@ class AgendaViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extend the window [PAST_CHUNK_DAYS] further back (#367). Ignored while the
|
* Extend the window further back (#367), doubling what's loaded (at least
|
||||||
* previous extension is still loading, so a scroll that keeps asking only
|
* [PAST_CHUNK_DAYS], at most [MAX_PAST_STEP_DAYS]) so a long scroll back
|
||||||
* moves one chunk at a time.
|
* re-queries only a few times. Ignored while the previous extension is
|
||||||
|
* still loading.
|
||||||
*/
|
*/
|
||||||
fun loadEarlier() {
|
fun loadEarlier() {
|
||||||
val loaded = (state.value as? AgendaUiState.Success)?.windowStart ?: return
|
val loaded = (state.value as? AgendaUiState.Success)?.windowStart ?: return
|
||||||
val window = _window.value
|
val window = _window.value
|
||||||
if (loaded != window.start) return
|
if (loaded != window.start) return
|
||||||
_window.value = window.copy(pastDays = window.pastDays + PAST_CHUNK_DAYS)
|
val step = window.pastDays.coerceIn(PAST_CHUNK_DAYS, MAX_PAST_STEP_DAYS)
|
||||||
|
_window.value = window.copy(pastDays = window.pastDays + step)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun rememberTopDay(date: LocalDate) {
|
fun rememberTopDay(date: LocalDate) {
|
||||||
@@ -233,6 +235,9 @@ class AgendaViewModel @Inject constructor(
|
|||||||
/** How many days each scroll back into the past loads (#367). */
|
/** How many days each scroll back into the past loads (#367). */
|
||||||
internal const val PAST_CHUNK_DAYS = 30
|
internal const val PAST_CHUNK_DAYS = 30
|
||||||
|
|
||||||
|
/** The most one scroll back adds, so an empty past grows the window by years, not exponentially. */
|
||||||
|
internal const val MAX_PAST_STEP_DAYS = 365
|
||||||
|
|
||||||
/** Inclusive instant range from the start of [anchor] through [days] days ahead. */
|
/** Inclusive instant range from the start of [anchor] through [days] days ahead. */
|
||||||
internal fun agendaRange(anchor: LocalDate, days: Int, zone: TimeZone): ClosedRange<Instant> =
|
internal fun agendaRange(anchor: LocalDate, days: Int, zone: TimeZone): ClosedRange<Instant> =
|
||||||
dayRange(anchor, anchor.plus(days, DateTimeUnit.DAY), zone)
|
dayRange(anchor, anchor.plus(days, DateTimeUnit.DAY), zone)
|
||||||
|
|||||||
@@ -26,8 +26,6 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
@@ -82,8 +80,7 @@ fun CalendarFabColumn(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Held past the selection clearing, so the bar has something to fold away.
|
// Held past the selection clearing, so the bar has something to fold away.
|
||||||
val heldBar = remember { mutableStateOf<(@Composable () -> Unit)?>(null) }
|
val heldBar = rememberHeld(selection?.bar)
|
||||||
if (selection != null && heldBar.value !== selection.bar) heldBar.value = selection.bar
|
|
||||||
// Held at the FAB's height so the taller bar can't shift the FAB.
|
// Held at the FAB's height so the taller bar can't shift the FAB.
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.height(FabSize),
|
modifier = Modifier.height(FabSize),
|
||||||
@@ -101,7 +98,7 @@ fun CalendarFabColumn(
|
|||||||
shrinkTowards = Alignment.End,
|
shrinkTowards = Alignment.End,
|
||||||
) + fadeOut(),
|
) + fadeOut(),
|
||||||
) {
|
) {
|
||||||
heldBar.value?.invoke()
|
heldBar?.invoke()
|
||||||
}
|
}
|
||||||
val label = stringResource(
|
val label = stringResource(
|
||||||
when {
|
when {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
@@ -100,33 +101,28 @@ suspend fun CalendarRepository.tryDeleteEvent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs an action that writes to the calendar, asking for `WRITE_CALENDAR` first
|
* [action], which writes to the calendar, as a handler that asks for
|
||||||
* where the install only holds read access (v1.0 installs). A grant continues
|
* `WRITE_CALENDAR` first where the install only holds read access (v1.0
|
||||||
* straight into the action that asked for it.
|
* installs). A grant continues into [action], also when it returns after a
|
||||||
|
* rotation; a refusal runs [onDenied].
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun rememberWriteGate(): (() -> Unit) -> Unit {
|
fun rememberWriteAction(onDenied: () -> Unit = {}, action: () -> Unit): () -> Unit {
|
||||||
val gate = rememberWriteGateOr()
|
|
||||||
return { action -> gate(action) {} }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** [rememberWriteGate], running the fallback instead when the user refuses. */
|
|
||||||
@Composable
|
|
||||||
fun rememberWriteGateOr(): (action: () -> Unit, fallback: () -> Unit) -> Unit {
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var pending by remember { mutableStateOf<Pair<() -> Unit, () -> Unit>?>(null) }
|
val currentAction by rememberUpdatedState(action)
|
||||||
|
val currentDenied by rememberUpdatedState(onDenied)
|
||||||
val launcher = rememberLauncherForActivityResult(
|
val launcher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.RequestPermission(),
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
) { granted ->
|
) { granted ->
|
||||||
pending?.let { (action, fallback) -> if (granted) action() else fallback() }
|
if (granted) currentAction() else currentDenied()
|
||||||
pending = null
|
|
||||||
}
|
}
|
||||||
return { action, fallback ->
|
return remember(context, launcher) {
|
||||||
if (context.canWriteCalendar()) {
|
{
|
||||||
action()
|
if (context.canWriteCalendar()) {
|
||||||
} else {
|
currentAction()
|
||||||
pending = action to fallback
|
} else {
|
||||||
launcher.launch(Manifest.permission.WRITE_CALENDAR)
|
launcher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||||
|
import de.jeanlucmakiola.calendula.data.time.ZoneSource
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
@@ -26,7 +27,6 @@ import kotlinx.coroutines.flow.flowOn
|
|||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -42,6 +42,7 @@ class EventActionsViewModel @Inject constructor(
|
|||||||
private val repository: CalendarRepository,
|
private val repository: CalendarRepository,
|
||||||
private val icsExporter: IcsExporter,
|
private val icsExporter: IcsExporter,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
|
private val zoneSource: ZoneSource,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
/** Every calendar by id, for what a selected event's bar may offer. */
|
/** Every calendar by id, for what a selected event's bar may offer. */
|
||||||
@@ -64,7 +65,7 @@ class EventActionsViewModel @Inject constructor(
|
|||||||
suspend fun duplicateForm(event: EventInstance): EventForm? {
|
suspend fun duplicateForm(event: EventInstance): EventForm? {
|
||||||
val detail = load(event) ?: return null
|
val detail = load(event) ?: return null
|
||||||
val canModify = calendars.value[event.calendarId]?.canModifyContents == true
|
val canModify = calendars.value[event.calendarId]?.canModifyContents == true
|
||||||
return detail.toDuplicateForm(canModify, TimeZone.currentSystemDefault())
|
return detail.toDuplicateForm(canModify, zoneSource.current())
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A staged `.ics` for [event], or null — reported — when it can't be built. */
|
/** A staged `.ics` for [event], or null — reported — when it can't be built. */
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ fun rememberFabSelection(
|
|||||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val requireWrite = rememberWriteGate()
|
|
||||||
val chooserTitle = stringResource(R.string.event_share_chooser_title)
|
val chooserTitle = stringResource(R.string.event_share_chooser_title)
|
||||||
|
|
||||||
// What the FAB has been told for this selection, over what the coverage says.
|
// What the FAB has been told for this selection, over what the coverage says.
|
||||||
@@ -43,10 +42,28 @@ fun rememberFabSelection(
|
|||||||
val currentEdit by rememberUpdatedState(onEdit)
|
val currentEdit by rememberUpdatedState(onEdit)
|
||||||
val currentDuplicate by rememberUpdatedState(onDuplicate)
|
val currentDuplicate by rememberUpdatedState(onDuplicate)
|
||||||
val currentDismissChip by rememberUpdatedState(onDismissChip)
|
val currentDismissChip by rememberUpdatedState(onDismissChip)
|
||||||
val currentWrite by rememberUpdatedState(requireWrite)
|
// Each reads the selection at grant time, which may come after a rotation.
|
||||||
|
val onCreate = rememberWriteAction { host.selection?.create?.invoke() }
|
||||||
|
val onEditSelected = rememberWriteAction {
|
||||||
|
host.selection?.takeIf { it.create == null }?.let { selected ->
|
||||||
|
selected.dismiss()
|
||||||
|
currentEdit(selected.event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val onDuplicateSelected = rememberWriteAction {
|
||||||
|
val selected = host.selection?.takeIf { it.create == null } ?: return@rememberWriteAction
|
||||||
|
scope.launch {
|
||||||
|
val form = viewModel.duplicateForm(selected.event) ?: return@launch
|
||||||
|
selected.dismiss()
|
||||||
|
currentDuplicate(form)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val onDeleteSelected = rememberWriteAction {
|
||||||
|
host.selection?.takeIf { it.create == null }?.let { viewModel.requestDelete(it.event) }
|
||||||
|
}
|
||||||
|
|
||||||
val selection = host.selection ?: return null
|
val selection = host.selection ?: return null
|
||||||
selection.create?.let { create ->
|
if (selection.create != null) {
|
||||||
return remember(selection, expanded, chipShown) {
|
return remember(selection, expanded, chipShown) {
|
||||||
FabSelection(
|
FabSelection(
|
||||||
expanded = expanded,
|
expanded = expanded,
|
||||||
@@ -56,7 +73,7 @@ fun rememberFabSelection(
|
|||||||
},
|
},
|
||||||
bar = {
|
bar = {
|
||||||
DraftSelectionBar(
|
DraftSelectionBar(
|
||||||
onCreate = { currentWrite { create() } },
|
onCreate = onCreate,
|
||||||
onDiscard = selection.dismiss,
|
onDiscard = selection.dismiss,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -78,28 +95,15 @@ fun rememberFabSelection(
|
|||||||
EventSelectionBar(
|
EventSelectionBar(
|
||||||
canEdit = canEdit,
|
canEdit = canEdit,
|
||||||
canDelete = canDelete,
|
canDelete = canDelete,
|
||||||
onEdit = {
|
onEdit = onEditSelected,
|
||||||
currentWrite {
|
onDuplicate = onDuplicateSelected,
|
||||||
selection.dismiss()
|
|
||||||
currentEdit(event)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDuplicate = {
|
|
||||||
currentWrite {
|
|
||||||
scope.launch {
|
|
||||||
val form = viewModel.duplicateForm(event) ?: return@launch
|
|
||||||
selection.dismiss()
|
|
||||||
currentDuplicate(form)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onShare = {
|
onShare = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val uri = viewModel.shareUri(event) ?: return@launch
|
val uri = viewModel.shareUri(event) ?: return@launch
|
||||||
if (!context.sendEventShare(uri, chooserTitle)) viewModel.reportShareFailed()
|
if (!context.sendEventShare(uri, chooserTitle)) viewModel.reportShareFailed()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDelete = { currentWrite { viewModel.requestDelete(event) } },
|
onDelete = onDeleteSelected,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import androidx.compose.runtime.derivedStateOf
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
@@ -111,14 +110,13 @@ fun HourGutter(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Held while it fades, so it goes out where it was rather than blinking off.
|
// Held while it fades, so it goes out where it was rather than blinking off.
|
||||||
val badgeMin = remember { mutableStateOf<Int?>(null) }
|
val badgeMin = rememberHeld(dragStartMin)
|
||||||
dragStartMin?.let { badgeMin.value = it }
|
|
||||||
val badgeIn by animateFloatAsState(
|
val badgeIn by animateFloatAsState(
|
||||||
targetValue = if (dragStartMin != null) 1f else 0f,
|
targetValue = if (dragStartMin != null) 1f else 0f,
|
||||||
animationSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
|
animationSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
|
||||||
label = "gutterBadge",
|
label = "gutterBadge",
|
||||||
)
|
)
|
||||||
badgeMin.value?.takeIf { badgeIn > 0f }?.let { startMin ->
|
badgeMin?.takeIf { badgeIn > 0f }?.let { startMin ->
|
||||||
val top = (hourHeight * (startMin / 60f) - BADGE_HEIGHT / 2).coerceAtLeast(0.dp)
|
val top = (hourHeight * (startMin / 60f) - BADGE_HEIGHT / 2).coerceAtLeast(0.dp)
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [live], or after it goes null the last value it had, so an overlay keeps
|
||||||
|
* drawing what it showed through its slide-out instead of flashing empty. Never
|
||||||
|
* a placeholder: until [live] is first set this is null, so a destination never
|
||||||
|
* loads a bogus key on its first frame. Not saved — after a recreation the
|
||||||
|
* saveable [live] is the value again.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun <T : Any> rememberHeld(live: T?): T? {
|
||||||
|
val held = remember { HeldValue<T>() }
|
||||||
|
if (live != null) held.value = live
|
||||||
|
return live ?: held.value
|
||||||
|
}
|
||||||
|
|
||||||
|
private class HeldValue<T : Any> {
|
||||||
|
var value: T? = null
|
||||||
|
}
|
||||||
@@ -95,7 +95,7 @@ import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
|||||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.DeleteEventDialog
|
import de.jeanlucmakiola.calendula.ui.common.DeleteEventDialog
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGate
|
import de.jeanlucmakiola.calendula.ui.common.rememberWriteAction
|
||||||
import de.jeanlucmakiola.calendula.ui.common.sendEventShare
|
import de.jeanlucmakiola.calendula.ui.common.sendEventShare
|
||||||
import de.jeanlucmakiola.calendula.ui.common.FieldCopier
|
import de.jeanlucmakiola.calendula.ui.common.FieldCopier
|
||||||
import de.jeanlucmakiola.calendula.ui.common.copyOnLongPress
|
import de.jeanlucmakiola.calendula.ui.common.copyOnLongPress
|
||||||
@@ -169,14 +169,11 @@ fun EventDetailScreen(
|
|||||||
|
|
||||||
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
|
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
|
||||||
// upgrade in place and continues straight into the tapped action.
|
// upgrade in place and continues straight into the tapped action.
|
||||||
val requireWrite = rememberWriteGate()
|
val onDeleteClick = rememberWriteAction { showDeleteDialog = true }
|
||||||
val onDeleteClick = { requireWrite { showDeleteDialog = true } }
|
val onEditClick = rememberWriteAction(action = onEdit)
|
||||||
val onEditClick = { requireWrite(onEdit) }
|
|
||||||
// Duplicate reads the loaded detail into a create form and hands it up; the
|
// Duplicate reads the loaded detail into a create form and hands it up; the
|
||||||
// create still needs WRITE_CALENDAR even when the source is read-only.
|
// create still needs WRITE_CALENDAR even when the source is read-only.
|
||||||
val onDuplicateClick = {
|
val onDuplicateClick = rememberWriteAction { viewModel.duplicateForm()?.let(onDuplicate) }
|
||||||
requireWrite { viewModel.duplicateForm()?.let(onDuplicate) }
|
|
||||||
}
|
|
||||||
|
|
||||||
val deleteFailedMessage = stringResource(R.string.event_delete_failed)
|
val deleteFailedMessage = stringResource(R.string.event_delete_failed)
|
||||||
val writeDeniedMessage = stringResource(R.string.event_delete_write_denied)
|
val writeDeniedMessage = stringResource(R.string.event_delete_write_denied)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||||
|
import de.jeanlucmakiola.calendula.data.time.ZoneSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
@@ -31,7 +32,6 @@ import kotlinx.coroutines.flow.flow
|
|||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -45,6 +45,7 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
private val repository: CalendarRepository,
|
private val repository: CalendarRepository,
|
||||||
private val icsExporter: IcsExporter,
|
private val icsExporter: IcsExporter,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
|
private val zoneSource: ZoneSource,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _target = MutableStateFlow<Target?>(null)
|
private val _target = MutableStateFlow<Target?>(null)
|
||||||
@@ -141,7 +142,7 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
fun duplicateForm(): EventForm? {
|
fun duplicateForm(): EventForm? {
|
||||||
val loaded = state.value as? EventDetailUiState.Success ?: return null
|
val loaded = state.value as? EventDetailUiState.Success ?: return null
|
||||||
return loaded.detail.toDuplicateForm(loaded.canModify, TimeZone.currentSystemDefault())
|
return loaded.detail.toDuplicateForm(loaded.canModify, zoneSource.current())
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
|
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import de.jeanlucmakiola.calendula.ui.common.sendEventShare
|
import de.jeanlucmakiola.calendula.ui.common.sendEventShare
|
||||||
import de.jeanlucmakiola.calendula.ui.common.canWriteCalendar
|
import de.jeanlucmakiola.calendula.ui.common.canWriteCalendar
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGate
|
import de.jeanlucmakiola.calendula.ui.common.rememberWriteAction
|
||||||
import de.jeanlucmakiola.calendula.ui.common.DeleteEventDialog
|
import de.jeanlucmakiola.calendula.ui.common.DeleteEventDialog
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.material.icons.filled.Share
|
import androidx.compose.material.icons.filled.Share
|
||||||
@@ -168,6 +168,7 @@ import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
|||||||
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.withRepeatedHourZone
|
import de.jeanlucmakiola.calendula.ui.common.withRepeatedHourZone
|
||||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
@@ -288,7 +289,6 @@ fun EventEditScreen(
|
|||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val requireWrite = rememberWriteGate()
|
|
||||||
// Save keeps its own launcher: its result calls straight into the ViewModel,
|
// Save keeps its own launcher: its result calls straight into the ViewModel,
|
||||||
// so a save waiting on the permission dialog survives a rotation.
|
// so a save waiting on the permission dialog survives a rotation.
|
||||||
val savePermissionLauncher = rememberLauncherForActivityResult(
|
val savePermissionLauncher = rememberLauncherForActivityResult(
|
||||||
@@ -313,14 +313,13 @@ fun EventEditScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The copy opens over this form; a form with nothing to lose steps aside.
|
// The copy opens over this form; a form with nothing to lose steps aside.
|
||||||
val onDuplicateClick: () -> Unit = {
|
val onDuplicateClick = rememberWriteAction {
|
||||||
requireWrite {
|
viewModel.duplicateForm()?.let { form ->
|
||||||
viewModel.duplicateForm()?.let { form ->
|
onDuplicate?.invoke(form)
|
||||||
onDuplicate?.invoke(form)
|
if (!hasUnsavedChanges) close()
|
||||||
if (!hasUnsavedChanges) close()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val onDeleteClick = rememberWriteAction { showDeleteDialog = true }
|
||||||
|
|
||||||
val saveFailedMessage = stringResource(R.string.event_edit_save_failed)
|
val saveFailedMessage = stringResource(R.string.event_edit_save_failed)
|
||||||
val writeDeniedMessage = stringResource(R.string.event_edit_write_denied)
|
val writeDeniedMessage = stringResource(R.string.event_edit_write_denied)
|
||||||
@@ -386,7 +385,7 @@ fun EventEditScreen(
|
|||||||
canDuplicate = onDuplicate != null,
|
canDuplicate = onDuplicate != null,
|
||||||
onDuplicate = onDuplicateClick,
|
onDuplicate = onDuplicateClick,
|
||||||
onShare = onShareClick,
|
onShare = onShareClick,
|
||||||
onDelete = { requireWrite { showDeleteDialog = true } },
|
onDelete = onDeleteClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Button(
|
Button(
|
||||||
@@ -1128,13 +1127,7 @@ private fun WhenCard(
|
|||||||
color = MaterialTheme.colorScheme.error,
|
color = MaterialTheme.colorScheme.error,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// The rows above edit a pinned event in *its own* zone, which is the
|
// A pinned event's local equivalent, shown only when pinned elsewhere.
|
||||||
// time it was set at — but that says nothing about when it lands for
|
|
||||||
// whoever is reading it. Spell the local equivalent out rather than
|
|
||||||
// leaving the user to do the offset arithmetic. Absent unless the
|
|
||||||
// event is pinned somewhere other than here.
|
|
||||||
// Keyed rather than recomputed: the form recomposes on every title
|
|
||||||
// keystroke, and this would otherwise re-parse the zone each time.
|
|
||||||
val pinnedAway = remember(form.timezone, form.isAllDay, deviceZone) {
|
val pinnedAway = remember(form.timezone, form.isAllDay, deviceZone) {
|
||||||
form.isPinnedAwayFrom(deviceZone)
|
form.isPinnedAwayFrom(deviceZone)
|
||||||
}
|
}
|
||||||
@@ -2421,9 +2414,8 @@ private fun accessLevelSummary(level: AccessLevel): Int = when (level) {
|
|||||||
private fun formatTimeRange(start: Instant, end: Instant, zone: TimeZone, locale: Locale): String {
|
private fun formatTimeRange(start: Instant, end: Instant, zone: TimeZone, locale: Locale): String {
|
||||||
val use24Hour = LocalUse24HourFormat.current
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
return remember(start, end, zone, locale, use24Hour) {
|
return remember(start, end, zone, locale, use24Hour) {
|
||||||
val timeFormat = timeOfDayFormatter(use24Hour, locale)
|
|
||||||
fun format(at: Instant) = withRepeatedHourZone(
|
fun format(at: Instant) = withRepeatedHourZone(
|
||||||
timeFormat.format(at.toLocalDateTime(zone).time.toJavaLocalTime()),
|
formatTimeOfDay(at, zone, use24Hour, locale),
|
||||||
at,
|
at,
|
||||||
zone,
|
zone,
|
||||||
locale,
|
locale,
|
||||||
|
|||||||
@@ -590,6 +590,9 @@ class EventEditViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The form as the user has it now, or null when none is open. */
|
||||||
|
fun currentForm(): EventForm? = _form.value
|
||||||
|
|
||||||
/** Forget the open form; the next [openNew]/[openForEdit] starts clean. */
|
/** Forget the open form; the next [openNew]/[openForEdit] starts clean. */
|
||||||
fun reset() {
|
fun reset() {
|
||||||
session++
|
session++
|
||||||
|
|||||||
@@ -90,8 +90,10 @@ fun ImportScreen(
|
|||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// A single event isn't shown here — it opens the create form for review.
|
// A single event isn't shown here — it opens the create form for review.
|
||||||
|
// Read live: the state composed with may be the previous import's result,
|
||||||
|
// which the load above has already replaced.
|
||||||
LaunchedEffect(state) {
|
LaunchedEffect(state) {
|
||||||
(state as? ImportUiState.Single)?.let { onOpenSingle(it.form); onClose() }
|
(viewModel.state.value as? ImportUiState.Single)?.let { onOpenSingle(it.form); onClose() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hoisted target calendar so the always-visible top-bar Import action can
|
// Hoisted target calendar so the always-visible top-bar Import action can
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.search
|
package de.jeanlucmakiola.calendula.ui.search
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.animateColorAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -59,7 +56,6 @@ import androidx.compose.ui.focus.FocusRequester
|
|||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@@ -80,11 +76,11 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
|||||||
import de.jeanlucmakiola.calendula.domain.SearchHit
|
import de.jeanlucmakiola.calendula.domain.SearchHit
|
||||||
import de.jeanlucmakiola.calendula.domain.SearchMonth
|
import de.jeanlucmakiola.calendula.domain.SearchMonth
|
||||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||||
import de.jeanlucmakiola.floret.android.hasPermission
|
|
||||||
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
||||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberWriteAction
|
||||||
import de.jeanlucmakiola.calendula.ui.common.declinedTitle
|
import de.jeanlucmakiola.calendula.ui.common.declinedTitle
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
@@ -133,7 +129,6 @@ fun SearchScreen(
|
|||||||
val deleteState by viewModel.deleteState.collectAsStateWithLifecycle()
|
val deleteState by viewModel.deleteState.collectAsStateWithLifecycle()
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
val keyboard = LocalSoftwareKeyboardController.current
|
val keyboard = LocalSoftwareKeyboardController.current
|
||||||
val context = LocalContext.current
|
|
||||||
val inSelection = selection.isNotEmpty()
|
val inSelection = selection.isNotEmpty()
|
||||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -155,24 +150,8 @@ fun SearchScreen(
|
|||||||
keyboard?.show()
|
keyboard?.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same in-place WRITE_CALENDAR upgrade the detail screen does: a v1.0 install
|
// A v1.0 install holds only READ_CALENDAR; granting continues into the held delete.
|
||||||
// holds only READ_CALENDAR, and granting continues into the held delete.
|
val onDeleteClick = rememberWriteAction { showDeleteDialog = true }
|
||||||
var pendingWrite by remember { mutableStateOf<(() -> Unit)?>(null) }
|
|
||||||
val writePermissionLauncher = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.RequestPermission(),
|
|
||||||
) { granted ->
|
|
||||||
if (granted) pendingWrite?.invoke()
|
|
||||||
pendingWrite = null
|
|
||||||
}
|
|
||||||
val requireWrite: (() -> Unit) -> Unit = { action ->
|
|
||||||
val granted = context.hasPermission(Manifest.permission.WRITE_CALENDAR)
|
|
||||||
if (granted) {
|
|
||||||
action()
|
|
||||||
} else {
|
|
||||||
pendingWrite = action
|
|
||||||
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Back leaves selection mode before it leaves the screen, and without the
|
// Back leaves selection mode before it leaves the screen, and without the
|
||||||
// predictive-back scale-out — that would read as search closing and snapping
|
// predictive-back scale-out — that would read as search closing and snapping
|
||||||
@@ -193,7 +172,7 @@ fun SearchScreen(
|
|||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onCloseSelection = viewModel::clearSelection,
|
onCloseSelection = viewModel::clearSelection,
|
||||||
onSelectAll = viewModel::selectAll,
|
onSelectAll = viewModel::selectAll,
|
||||||
onDelete = { requireWrite { showDeleteDialog = true } },
|
onDelete = onDeleteClick,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.EventSearch
|
import de.jeanlucmakiola.calendula.domain.EventSearch
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.deleteEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.SearchResults
|
import de.jeanlucmakiola.calendula.domain.SearchResults
|
||||||
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
||||||
import de.jeanlucmakiola.floret.di.IoDispatcher
|
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||||
@@ -196,15 +197,11 @@ class SearchViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun deleteOne(event: EventInstance, scope: RecurringWriteScope) {
|
private suspend fun deleteOne(event: EventInstance, scope: RecurringWriteScope) {
|
||||||
val begin = event.start.toEpochMilliseconds()
|
repository.deleteEvent(
|
||||||
when {
|
eventId = event.eventId,
|
||||||
!event.isRecurring -> repository.deleteEvent(event.eventId)
|
beginMillis = event.start.toEpochMilliseconds(),
|
||||||
scope == RecurringWriteScope.ThisEvent ->
|
scope = if (event.isRecurring) scope else RecurringWriteScope.AllEvents,
|
||||||
repository.deleteOccurrence(event.eventId, begin)
|
)
|
||||||
scope == RecurringWriteScope.ThisAndFollowing ->
|
|
||||||
repository.deleteEventFromOccurrence(event.eventId, begin)
|
|
||||||
else -> repository.deleteEvent(event.eventId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun readOnlyCalendarIds(): Set<Long> =
|
private suspend fun readOnlyCalendarIds(): Set<Long> =
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import de.jeanlucmakiola.calendula.R
|
|||||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A synchronous copy of the theme choice, for the window backdrop drawn before
|
* A synchronous copy of the theme and dynamic-colour choices, for the window
|
||||||
* the first Compose frame. The real setting lives in DataStore, which only
|
* backdrop and the first Compose frame. The real setting lives in DataStore, which only
|
||||||
* answers asynchronously — too late for onCreate, so a Light, Dark or Black
|
* answers asynchronously — too late for onCreate, so a Light, Dark or Black
|
||||||
* override flashed the night-qualifier backdrop on a cold start or a
|
* override flashed the night-qualifier backdrop on a cold start or a
|
||||||
* recreation (#181). MainActivity mirrors every stored value here and reads it
|
* recreation (#181). MainActivity mirrors every stored value here and reads it
|
||||||
@@ -18,6 +18,7 @@ internal object WindowThemeCache {
|
|||||||
|
|
||||||
private const val FILE = "window_theme"
|
private const val FILE = "window_theme"
|
||||||
private const val KEY_THEME_MODE = "theme_mode"
|
private const val KEY_THEME_MODE = "theme_mode"
|
||||||
|
private const val KEY_DYNAMIC_COLOR = "dynamic_color"
|
||||||
|
|
||||||
/** The last mirrored theme choice, or null before the first mirror. */
|
/** The last mirrored theme choice, or null before the first mirror. */
|
||||||
fun read(context: Context): ThemeMode? =
|
fun read(context: Context): ThemeMode? =
|
||||||
@@ -25,10 +26,23 @@ internal object WindowThemeCache {
|
|||||||
.getString(KEY_THEME_MODE, null)
|
.getString(KEY_THEME_MODE, null)
|
||||||
?.let { name -> ThemeMode.entries.firstOrNull { it.name == name } }
|
?.let { name -> ThemeMode.entries.firstOrNull { it.name == name } }
|
||||||
|
|
||||||
fun write(context: Context, mode: ThemeMode) {
|
/** The last mirrored dynamic-colour choice, or null before the first mirror. */
|
||||||
|
fun readDynamicColor(context: Context): Boolean? {
|
||||||
val prefs = context.getSharedPreferences(FILE, Context.MODE_PRIVATE)
|
val prefs = context.getSharedPreferences(FILE, Context.MODE_PRIVATE)
|
||||||
if (prefs.getString(KEY_THEME_MODE, null) == mode.name) return
|
return if (prefs.contains(KEY_DYNAMIC_COLOR)) prefs.getBoolean(KEY_DYNAMIC_COLOR, true) else null
|
||||||
prefs.edit { putString(KEY_THEME_MODE, mode.name) }
|
}
|
||||||
|
|
||||||
|
fun write(context: Context, mode: ThemeMode, dynamicColor: Boolean) {
|
||||||
|
val prefs = context.getSharedPreferences(FILE, Context.MODE_PRIVATE)
|
||||||
|
if (prefs.getString(KEY_THEME_MODE, null) == mode.name &&
|
||||||
|
prefs.contains(KEY_DYNAMIC_COLOR) && prefs.getBoolean(KEY_DYNAMIC_COLOR, true) == dynamicColor
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prefs.edit {
|
||||||
|
putString(KEY_THEME_MODE, mode.name)
|
||||||
|
putBoolean(KEY_DYNAMIC_COLOR, dynamicColor)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ import de.jeanlucmakiola.calendula.ui.common.draftEvent
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.draftStartMin
|
import de.jeanlucmakiola.calendula.ui.common.draftStartMin
|
||||||
import de.jeanlucmakiola.calendula.ui.common.isDraft
|
import de.jeanlucmakiola.calendula.ui.common.isDraft
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface
|
import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGate
|
import de.jeanlucmakiola.calendula.ui.common.rememberWriteAction
|
||||||
import de.jeanlucmakiola.calendula.ui.common.OutOfViewEdges
|
import de.jeanlucmakiola.calendula.ui.common.OutOfViewEdges
|
||||||
import de.jeanlucmakiola.calendula.ui.common.wallClockInstant
|
import de.jeanlucmakiola.calendula.ui.common.wallClockInstant
|
||||||
import de.jeanlucmakiola.calendula.ui.common.PickUp
|
import de.jeanlucmakiola.calendula.ui.common.PickUp
|
||||||
@@ -638,10 +638,15 @@ private fun WeekContent(
|
|||||||
delay(SELECTION_ORPHAN_GRACE_MILLIS)
|
delay(SELECTION_ORPHAN_GRACE_MILLIS)
|
||||||
dragController.deselect()
|
dragController.deselect()
|
||||||
}
|
}
|
||||||
val requireWrite = rememberWriteGate()
|
var pendingDraft by remember { mutableStateOf<EventInstance?>(null) }
|
||||||
|
val createPendingDraft = rememberWriteAction {
|
||||||
|
pendingDraft?.let(createDraft)
|
||||||
|
pendingDraft = null
|
||||||
|
}
|
||||||
val openEvent: (EventInstance) -> Unit = { event ->
|
val openEvent: (EventInstance) -> Unit = { event ->
|
||||||
if (event.isDraft) {
|
if (event.isDraft) {
|
||||||
requireWrite { createDraft(event) }
|
pendingDraft = event
|
||||||
|
createPendingDraft()
|
||||||
} else {
|
} else {
|
||||||
dragController.deselect()
|
dragController.deselect()
|
||||||
onEventClick(event)
|
onEventClick(event)
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ class HtmlTextTest {
|
|||||||
assertThat(looksLikeHtml("See <a href=\"https://x.y\">here</a>")).isTrue()
|
assertThat(looksLikeHtml("See <a href=\"https://x.y\">here</a>")).isTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `angle-bracketed links and addresses stay text`() {
|
||||||
|
val html = "Join<br>here <https://teams.microsoft.com/l/x> or <a@b.c>"
|
||||||
|
assertThat(parseHtml(html).text).isEqualTo("Join\nhere <https://teams.microsoft.com/l/x> or <a@b.c>")
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `plain text passes through untouched`() {
|
fun `plain text passes through untouched`() {
|
||||||
val text = " Agenda:\n\n\n- one \n- two"
|
val text = " Agenda:\n\n\n- one \n- two"
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ class IcsRecurrenceTest {
|
|||||||
assertThat(sanitizeRrule("RRULE:FREQ=DAILY").rule).isEqualTo("FREQ=DAILY")
|
assertThat(sanitizeRrule("RRULE:FREQ=DAILY").rule).isEqualTo("FREQ=DAILY")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `part names and the prefix are matched case-insensitively`() {
|
||||||
|
val result = sanitizeRrule("rrule:freq=weekly;byday=mo")
|
||||||
|
|
||||||
|
assertThat(result.rule).isEqualTo("FREQ=WEEKLY;BYDAY=MO")
|
||||||
|
assertThat(result.repaired).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `an empty list part is dropped`() {
|
fun `an empty list part is dropped`() {
|
||||||
val bareByDay = sanitizeRrule("FREQ=WEEKLY;INTERVAL=1;BYDAY=")
|
val bareByDay = sanitizeRrule("FREQ=WEEKLY;INTERVAL=1;BYDAY=")
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.google.common.truth.Truth.assertThat
|
|||||||
import de.jeanlucmakiola.calendula.MainDispatcherExtension
|
import de.jeanlucmakiola.calendula.MainDispatcherExtension
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
|
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
|
||||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||||
|
import de.jeanlucmakiola.calendula.data.time.FakeZoneSource
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.testDetail
|
import de.jeanlucmakiola.calendula.testDetail
|
||||||
import de.jeanlucmakiola.calendula.testEvent
|
import de.jeanlucmakiola.calendula.testEvent
|
||||||
@@ -56,7 +57,7 @@ class EventDetailViewModelTest {
|
|||||||
settings = testSettings(tempDir, CoroutineScope(dispatcher)),
|
settings = testSettings(tempDir, CoroutineScope(dispatcher)),
|
||||||
)
|
)
|
||||||
// Only `shareUri()` touches the exporter, and nothing here shares.
|
// Only `shareUri()` touches the exporter, and nothing here shares.
|
||||||
return EventDetailViewModel(repo, IcsExporter(ContextWrapper(null)), dispatcher)
|
return EventDetailViewModel(repo, IcsExporter(ContextWrapper(null)), dispatcher, FakeZoneSource())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fakeSource(description: () -> String?) = FakeCalendarDataSource().apply {
|
private fun fakeSource(description: () -> String?) = FakeCalendarDataSource().apply {
|
||||||
|
|||||||
Reference in New Issue
Block a user