Compare commits

..
13 changed files with 113 additions and 163 deletions

No files matched your search

@@ -32,11 +32,6 @@ 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,6 +5,7 @@ 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
@@ -18,7 +19,9 @@ 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
@@ -110,6 +113,11 @@ 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)
@@ -181,6 +189,24 @@ 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(
@@ -0,0 +1,14 @@
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,6 +90,7 @@ 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
@@ -884,9 +885,6 @@ 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 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")
}
val opened = mapsSearchIntents(query).any { runCatching { context.startActivity(it) }.isSuccess }
if (!opened) Toast.makeText(context, R.string.link_open_failed, Toast.LENGTH_SHORT).show()
}
@@ -1,29 +1,19 @@
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
@@ -56,8 +46,7 @@ fun CalendarFilterList(
is FilterUiState.Success -> FilterList(
groups = s.groups,
hidden = s.hidden,
onToggleCalendar = viewModel::toggleCalendar,
onToggleGroup = viewModel::toggleGroup,
onSetVisible = viewModel::setVisible,
modifier = modifier,
)
}
@@ -72,17 +61,21 @@ fun CalendarFilterList(
private fun FilterList(
groups: List<CalendarAccountGroup>,
hidden: Set<Long>,
onToggleCalendar: (Long) -> Unit,
onToggleGroup: (List<Long>) -> Unit,
onSetVisible: (Long, Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.fillMaxWidth()) {
groups.forEach { group ->
val ids = group.calendars.map { it.id }
GroupHeader(
title = accountGroupTitle(group),
state = groupVisibility(ids, hidden),
onToggle = { onToggleGroup(ids) },
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,
),
)
group.calendars.forEachIndexed { index, cal ->
val visible = cal.id !in hidden
@@ -94,54 +87,16 @@ private fun FilterList(
trailing = {
Checkbox(
checked = visible,
onCheckedChange = { onToggleCalendar(cal.id) },
onCheckedChange = { onSetVisible(cal.id, it) },
)
},
onClick = { onToggleCalendar(cal.id) },
onClick = { onSetVisible(cal.id, !visible) },
)
}
}
}
}
/**
* 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,6 +14,7 @@ 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
@@ -47,19 +48,13 @@ class FilterViewModel @Inject constructor(
initialValue = FilterUiState.Loading,
)
/**
* 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) {
/** Show or hide a single calendar; persists the new hidden set. */
fun setVisible(calendarId: Long, visible: Boolean) {
viewModelScope.launch {
prefs.updateHiddenCalendarIds { if (calendarId in it) it - calendarId else it + calendarId }
val current = prefs.hiddenCalendarIds.first()
val next = if (visible) current - calendarId else current + calendarId
if (next != current) prefs.setHiddenCalendarIds(next)
}
}
fun toggleGroup(calendarIds: List<Long>) {
viewModelScope.launch { prefs.updateHiddenCalendarIds { toggledGroup(it, calendarIds) } }
}
}
@@ -1,20 +0,0 @@
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
@@ -0,0 +1,12 @@
<?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>
-3
View File
@@ -550,9 +550,6 @@
<!-- 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,16 +34,6 @@ 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")
@@ -0,0 +1,37 @@
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")
}
}
@@ -1,49 +0,0 @@
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))
}
}