Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9511444c6b | ||
|
|
4186640a63 | ||
|
|
22173be22e | ||
|
|
60166b349d |
No files matched your search
@@ -32,6 +32,11 @@ class CalendarPrefs @Inject constructor(
|
||||
|
||||
suspend fun setHiddenCalendarIds(ids: Set<Long>) = prefs.set(HIDDEN_IDS, ids)
|
||||
|
||||
/** Rewrite the drawer's hidden set atomically from its current value. */
|
||||
suspend fun updateHiddenCalendarIds(transform: (Set<Long>) -> Set<Long>) {
|
||||
prefs.update(HIDDEN_IDS, transform)
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch-offs the provider does not know about yet, because writing
|
||||
* `Calendars.VISIBLE` needs `WRITE_CALENDAR` (#75). Also inherits the
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
@@ -19,9 +18,7 @@ import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.reminderMapQuery
|
||||
import de.jeanlucmakiola.floret.android.hasPermission
|
||||
import de.jeanlucmakiola.floret.android.resolvableMapsSearchIntent
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.datetime.isoDayNumber
|
||||
import java.time.DayOfWeek
|
||||
@@ -113,11 +110,6 @@ class ReminderNotifier @Inject constructor(
|
||||
context.getString(R.string.reminder_action_dismiss),
|
||||
actionIntent(alert, ReminderActionReceiver.ACTION_DISMISS),
|
||||
)
|
||||
.apply {
|
||||
mapIntent(alert)?.let {
|
||||
addAction(R.drawable.ic_notification_map, context.getString(R.string.event_detail_open_in_maps), it)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
cancelLegacy(alert)
|
||||
@@ -189,24 +181,6 @@ class ReminderNotifier @Inject constructor(
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
/**
|
||||
* Opens [alert]'s location in maps like the detail screen does, started
|
||||
* directly (no receiver trampoline). Leaves the notification up for a later
|
||||
* snooze or dismiss. Null without a place to look up (a bare meeting link
|
||||
* isn't one) or an app to open it.
|
||||
*/
|
||||
private fun mapIntent(alert: ReminderAlert): PendingIntent? {
|
||||
val query = reminderMapQuery(alert.location) ?: return null
|
||||
val intent = context.resolvableMapsSearchIntent(query) ?: return null
|
||||
return PendingIntent.getActivity(
|
||||
context,
|
||||
// An implicit activity intent: filterEquals already keeps it apart from the others.
|
||||
/* requestCode = */ 0,
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
}
|
||||
|
||||
/** Channel creation is idempotent; re-running refreshes the localized name. */
|
||||
private fun ensureChannel() {
|
||||
val channel = NotificationChannel(
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.domain.reminders
|
||||
|
||||
// Same link shape the detail screen linkifies (ui/detail/WebLinks.kt).
|
||||
private val webLinkRegex = Regex("""https?://[^\s<>"]+""", RegexOption.IGNORE_CASE)
|
||||
|
||||
/**
|
||||
* What a reminder's "Open in maps" button looks up for [location] — the same
|
||||
* text the detail screen's location card opens in maps — or null when there is
|
||||
* no place to find: blank, or nothing but web (meeting) links.
|
||||
*/
|
||||
fun reminderMapQuery(location: String?): String? {
|
||||
val trimmed = location?.trim()?.takeIf { it.isNotEmpty() } ?: return null
|
||||
return trimmed.takeIf { webLinkRegex.replace(it, "").any(Char::isLetterOrDigit) }
|
||||
}
|
||||
@@ -90,7 +90,6 @@ import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
||||
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
||||
import de.jeanlucmakiola.calendula.domain.zoneOrNull
|
||||
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
|
||||
import de.jeanlucmakiola.floret.android.mapsSearchIntents
|
||||
import de.jeanlucmakiola.floret.components.IconInfoCard
|
||||
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
@@ -885,6 +884,9 @@ private fun startOrSayNoApp(context: Context, intent: Intent) {
|
||||
|
||||
/** Open a maps intent for [query]; fall back to a web maps URL if no app handles geo:. */
|
||||
private fun openInMaps(context: Context, query: String) {
|
||||
val opened = mapsSearchIntents(query).any { runCatching { context.startActivity(it) }.isSuccess }
|
||||
if (!opened) Toast.makeText(context, R.string.link_open_failed, Toast.LENGTH_SHORT).show()
|
||||
val encoded = Uri.encode(query)
|
||||
val geo = Intent(Intent.ACTION_VIEW, Uri.parse("geo:0,0?q=$encoded"))
|
||||
if (runCatching { context.startActivity(geo) }.isFailure) {
|
||||
openWebLink(context, "https://www.google.com/maps/search/?api=1&query=$encoded")
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,29 @@
|
||||
package de.jeanlucmakiola.calendula.ui.filter
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TriStateCheckbox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.toggleableState
|
||||
import androidx.compose.ui.state.ToggleableState
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
@@ -46,7 +56,8 @@ fun CalendarFilterList(
|
||||
is FilterUiState.Success -> FilterList(
|
||||
groups = s.groups,
|
||||
hidden = s.hidden,
|
||||
onSetVisible = viewModel::setVisible,
|
||||
onToggleCalendar = viewModel::toggleCalendar,
|
||||
onToggleGroup = viewModel::toggleGroup,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -61,21 +72,17 @@ fun CalendarFilterList(
|
||||
private fun FilterList(
|
||||
groups: List<CalendarAccountGroup>,
|
||||
hidden: Set<Long>,
|
||||
onSetVisible: (Long, Boolean) -> Unit,
|
||||
onToggleCalendar: (Long) -> Unit,
|
||||
onToggleGroup: (List<Long>) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
groups.forEach { group ->
|
||||
Text(
|
||||
text = accountGroupTitle(group),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(
|
||||
start = GroupedListInset,
|
||||
end = GroupedListInset,
|
||||
top = 12.dp,
|
||||
bottom = 4.dp,
|
||||
),
|
||||
val ids = group.calendars.map { it.id }
|
||||
GroupHeader(
|
||||
title = accountGroupTitle(group),
|
||||
state = groupVisibility(ids, hidden),
|
||||
onToggle = { onToggleGroup(ids) },
|
||||
)
|
||||
group.calendars.forEachIndexed { index, cal ->
|
||||
val visible = cal.id !in hidden
|
||||
@@ -87,16 +94,54 @@ private fun FilterList(
|
||||
trailing = {
|
||||
Checkbox(
|
||||
checked = visible,
|
||||
onCheckedChange = { onSetVisible(cal.id, it) },
|
||||
onCheckedChange = { onToggleCalendar(cal.id) },
|
||||
)
|
||||
},
|
||||
onClick = { onSetVisible(cal.id, !visible) },
|
||||
onClick = { onToggleCalendar(cal.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An account's header doubling as the group's parent checkbox (#143): checked
|
||||
* when every calendar shows, indeterminate when some do. Tapping it shows all,
|
||||
* or hides all when the group is fully shown.
|
||||
*/
|
||||
@Composable
|
||||
private fun GroupHeader(
|
||||
title: String,
|
||||
state: ToggleableState,
|
||||
onToggle: () -> Unit,
|
||||
) {
|
||||
val label = stringResource(
|
||||
if (state.nextGroupVisible()) R.string.filter_group_show_all else R.string.filter_group_hide_all,
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = GroupedListInset / 2)
|
||||
.clip(MaterialTheme.shapes.large)
|
||||
.clickable(onClickLabel = label, role = Role.Checkbox) { onToggle() }
|
||||
.semantics { toggleableState = state }
|
||||
.padding(start = GroupedListInset / 2, end = GroupedListInset / 2 + 16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 8.dp),
|
||||
)
|
||||
Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) {
|
||||
TriStateCheckbox(state = state, onClick = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilterLoading(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
|
||||
@@ -14,7 +14,6 @@ import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
@@ -48,13 +47,19 @@ class FilterViewModel @Inject constructor(
|
||||
initialValue = FilterUiState.Loading,
|
||||
)
|
||||
|
||||
/** Show or hide a single calendar; persists the new hidden set. */
|
||||
fun setVisible(calendarId: Long, visible: Boolean) {
|
||||
/**
|
||||
* Flip one calendar, or a whole group from its header (#143). Both decide
|
||||
* against the stored set rather than the last drawn state, so quick repeated
|
||||
* taps still alternate.
|
||||
*/
|
||||
fun toggleCalendar(calendarId: Long) {
|
||||
viewModelScope.launch {
|
||||
val current = prefs.hiddenCalendarIds.first()
|
||||
val next = if (visible) current - calendarId else current + calendarId
|
||||
if (next != current) prefs.setHiddenCalendarIds(next)
|
||||
prefs.updateHiddenCalendarIds { if (calendarId in it) it - calendarId else it + calendarId }
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleGroup(calendarIds: List<Long>) {
|
||||
viewModelScope.launch { prefs.updateHiddenCalendarIds { toggledGroup(it, calendarIds) } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package de.jeanlucmakiola.calendula.ui.filter
|
||||
|
||||
import androidx.compose.ui.state.ToggleableState
|
||||
|
||||
/** The drawer group header's checkbox state: on, off, or some of [calendarIds] shown. */
|
||||
internal fun groupVisibility(calendarIds: Collection<Long>, hidden: Set<Long>): ToggleableState {
|
||||
val shown = calendarIds.count { it !in hidden }
|
||||
return when (shown) {
|
||||
0 -> ToggleableState.Off
|
||||
calendarIds.size -> ToggleableState.On
|
||||
else -> ToggleableState.Indeterminate
|
||||
}
|
||||
}
|
||||
|
||||
/** What tapping the header does: a fully shown group hides, anything else shows all. */
|
||||
internal fun ToggleableState.nextGroupVisible(): Boolean = this != ToggleableState.On
|
||||
|
||||
/** [hidden] after tapping the header of the group holding [calendarIds]. */
|
||||
internal fun toggledGroup(hidden: Set<Long>, calendarIds: Collection<Long>): Set<Long> =
|
||||
if (groupVisibility(calendarIds, hidden).nextGroupVisible()) hidden - calendarIds.toSet() else hidden + calendarIds
|
||||
@@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Monochrome notification-action mark: Material "place" glyph. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12,2C8.13,2 5,5.13 5,9c0,5.25 7,13 7,13s7,-7.75 7,-13c0,-3.87 -3.13,-7 -7,-7zM12,11.5c-1.38,0 -2.5,-1.12 -2.5,-2.5s1.12,-2.5 2.5,-2.5 2.5,1.12 2.5,2.5 -1.12,2.5 -2.5,2.5z" />
|
||||
</vector>
|
||||
@@ -550,6 +550,9 @@
|
||||
|
||||
<!-- Calendar filter (M3) -->
|
||||
<string name="filter_title">Calendars</string>
|
||||
<!-- TalkBack action on a drawer account header that toggles all its calendars -->
|
||||
<string name="filter_group_show_all">Show all calendars in this group</string>
|
||||
<string name="filter_group_hide_all">Hide all calendars in this group</string>
|
||||
|
||||
<!-- Settings (M4) -->
|
||||
<string name="settings_title">Settings</string>
|
||||
|
||||
@@ -34,6 +34,16 @@ class CalendarPrefsTest {
|
||||
assertThat(prefs.hiddenCalendarIds.first()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateHiddenCalendarIds transforms the stored set`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = testPrefs(tempDir)
|
||||
prefs.setHiddenCalendarIds(setOf(9L))
|
||||
prefs.updateHiddenCalendarIds { it + listOf(1L, 2L) }
|
||||
assertThat(prefs.hiddenCalendarIds.first()).isEqualTo(setOf(1L, 2L, 9L))
|
||||
prefs.updateHiddenCalendarIds { emptySet() }
|
||||
assertThat(prefs.hiddenCalendarIds.first()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `garbage stored string is parsed defensively`(@TempDir tempDir: Path) = runTest {
|
||||
val store = testDataStore(tempDir, "test_prefs")
|
||||
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.domain.reminders
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ReminderMapQueryTest {
|
||||
|
||||
@Test
|
||||
fun `an address is looked up as written`() {
|
||||
assertThat(reminderMapQuery(" Main St 1, Berlin ")).isEqualTo("Main St 1, Berlin")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no location means no lookup`() {
|
||||
assertThat(reminderMapQuery(null)).isNull()
|
||||
assertThat(reminderMapQuery(" ")).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bare meeting link is not a place`() {
|
||||
assertThat(reminderMapQuery("https://meet.google.com/abc-defg-hij")).isNull()
|
||||
assertThat(reminderMapQuery("HTTP://example.com")).isNull()
|
||||
assertThat(reminderMapQuery("<https://teams.microsoft.com/l/meetup-join/x>")).isNull()
|
||||
assertThat(reminderMapQuery("https://a.example\nhttps://b.example")).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dotted place names are still places`() {
|
||||
assertThat(reminderMapQuery("Hauptstr. 12/3, Wien")).isEqualTo("Hauptstr. 12/3, Wien")
|
||||
assertThat(reminderMapQuery("St.Gallen/Zentrum")).isEqualTo("St.Gallen/Zentrum")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a place with a link beside it is still looked up`() {
|
||||
assertThat(reminderMapQuery("Room 4, https://example.com/map")).isEqualTo("Room 4, https://example.com/map")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package de.jeanlucmakiola.calendula.ui.filter
|
||||
|
||||
import androidx.compose.ui.state.ToggleableState
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class GroupVisibilityTest {
|
||||
|
||||
private val group = listOf(1L, 2L, 3L)
|
||||
|
||||
@Test
|
||||
fun `all shown is on and hides on tap`() {
|
||||
val state = groupVisibility(group, hidden = setOf(9L))
|
||||
assertThat(state).isEqualTo(ToggleableState.On)
|
||||
assertThat(state.nextGroupVisible()).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `none shown is off and shows on tap`() {
|
||||
val state = groupVisibility(group, hidden = setOf(1L, 2L, 3L))
|
||||
assertThat(state).isEqualTo(ToggleableState.Off)
|
||||
assertThat(state.nextGroupVisible()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `some shown is indeterminate and shows all on tap`() {
|
||||
val state = groupVisibility(group, hidden = setOf(2L))
|
||||
assertThat(state).isEqualTo(ToggleableState.Indeterminate)
|
||||
assertThat(state.nextGroupVisible()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty group is off`() {
|
||||
assertThat(groupVisibility(emptyList(), hidden = emptySet())).isEqualTo(ToggleableState.Off)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggling twice from the stored set alternates`() {
|
||||
val hidden = setOf(9L)
|
||||
val once = toggledGroup(hidden, group)
|
||||
assertThat(once).isEqualTo(setOf(1L, 2L, 3L, 9L))
|
||||
assertThat(toggledGroup(once, group)).isEqualTo(setOf(9L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggling a partly shown group shows all`() {
|
||||
assertThat(toggledGroup(setOf(2L, 9L), group)).isEqualTo(setOf(9L))
|
||||
}
|
||||
}
|
||||
+1
-1
Submodule floret-kit updated: aa6398e4d5...a039ab5bd4.
Reference in new issue
Block a user