feat(contacts): settings sub-page for contact special dates
A dedicated sub-page under Settings drives the mirror:
- master enable toggle — requests READ_CONTACTS contextually (only here, never at
startup), runs an immediate sync on enable, and on disable confirms then tears
the managed calendars down;
- per-type toggles (Birthdays / Anniversaries / Other), each confirm-guarded
since turning one off deletes its calendar;
- an editable title template ({name}/{age}) and a Show-age toggle;
- a "Sync now" row with the last-run time, a paused/permission banner with a
re-grant button, and a pointer to per-calendar colour/visibility/reminders.
Exposed via a separate SpecialDatesUiState flow (the main settings combine is
already at capacity); the ViewModel gained the engine/scheduler wiring.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,7 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.BugReport
|
||||
import androidx.compose.material.icons.filled.Cake
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.Dashboard
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
@@ -91,6 +92,7 @@ import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
||||
import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker
|
||||
@@ -121,7 +123,7 @@ import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Calendar
|
||||
|
||||
/** The settings sub-screens reached from the hub's category rows. */
|
||||
private enum class SettingsSection { Appearance, EventForm, Notifications }
|
||||
private enum class SettingsSection { Appearance, EventForm, Notifications, SpecialDates }
|
||||
|
||||
/**
|
||||
* Token-based accent for a leading icon chip (container / on-container pair).
|
||||
@@ -180,6 +182,13 @@ fun SettingsScreen(
|
||||
) {
|
||||
NotificationsScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = section == SettingsSection.SpecialDates,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
) {
|
||||
SpecialDatesScreen(viewModel = viewModel, onBack = { section = null })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,6 +234,13 @@ private fun SettingsHub(
|
||||
leading = { CategoryIcon(Icons.Default.CalendarMonth, ChipAccent.Tertiary) },
|
||||
onClick = onManageCalendars,
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_section_special_dates),
|
||||
summary = stringResource(R.string.settings_special_dates_subtitle),
|
||||
position = Position.Middle,
|
||||
leading = { CategoryIcon(Icons.Default.Cake, ChipAccent.Tertiary) },
|
||||
onClick = { onOpenSection(SettingsSection.SpecialDates) },
|
||||
)
|
||||
LanguageRow(position = Position.Middle)
|
||||
// One-tap add of the "New event" Quick Settings tile. The system prompt
|
||||
// is API 33+; on older versions the tile is still addable manually from
|
||||
@@ -1025,6 +1041,257 @@ private fun NotificationsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Contact special dates (issue #15)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun SpecialDatesScreen(
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val state by viewModel.specialDatesState.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
// READ_CONTACTS is requested only here, on enable — never at startup. On a
|
||||
// grant we either enable (if turning on) or just re-sync (clearing a stalled
|
||||
// banner after the permission was re-granted).
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
if (granted) {
|
||||
if (!state.enabled) viewModel.setSpecialDatesEnabled(true) else viewModel.syncSpecialDatesNow()
|
||||
}
|
||||
}
|
||||
val requestOrEnable: () -> Unit = {
|
||||
if (hasContactsPermission(context)) {
|
||||
viewModel.setSpecialDatesEnabled(true)
|
||||
} else {
|
||||
permissionLauncher.launch(Manifest.permission.READ_CONTACTS)
|
||||
}
|
||||
}
|
||||
|
||||
var confirmDisableAll by remember { mutableStateOf(false) }
|
||||
var confirmDisableType by remember { mutableStateOf<SpecialDateType?>(null) }
|
||||
var editTemplate by remember { mutableStateOf<SpecialDateType?>(null) }
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_special_dates),
|
||||
onBack = onBack,
|
||||
) {
|
||||
// Paused banner: the permission was revoked after enabling.
|
||||
if (state.enabled && state.stalledPermission) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_special_dates_paused_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.settings_special_dates_paused_hint),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
FilledTonalButton(
|
||||
onClick = { permissionLauncher.launch(Manifest.permission.READ_CONTACTS) },
|
||||
) { Text(stringResource(R.string.settings_special_dates_grant)) }
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_special_dates_enable),
|
||||
summary = stringResource(R.string.settings_special_dates_enable_hint),
|
||||
position = Position.Alone,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.enabled,
|
||||
onCheckedChange = { want -> if (want) requestOrEnable() else confirmDisableAll = true },
|
||||
)
|
||||
},
|
||||
onClick = { if (state.enabled) confirmDisableAll = true else requestOrEnable() },
|
||||
)
|
||||
|
||||
if (state.enabled) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
// Per-type toggles + their title templates.
|
||||
SpecialDateType.entries.forEachIndexed { index, type ->
|
||||
val on = type in state.types
|
||||
GroupedRow(
|
||||
title = stringResource(specialDateTypeLabel(type)),
|
||||
position = if (index == 0) Position.Top else Position.Middle,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = on,
|
||||
onCheckedChange = { want ->
|
||||
if (want) viewModel.setSpecialDateTypeEnabled(type, true)
|
||||
else confirmDisableType = type
|
||||
},
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
if (on) confirmDisableType = type else viewModel.setSpecialDateTypeEnabled(type, true)
|
||||
},
|
||||
)
|
||||
}
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_special_dates_template),
|
||||
summary = state.titleTemplates[SpecialDateType.Birthday].orEmpty(),
|
||||
position = Position.Bottom,
|
||||
onClick = { editTemplate = SpecialDateType.Birthday },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_special_dates_show_age),
|
||||
summary = stringResource(R.string.settings_special_dates_show_age_hint),
|
||||
position = Position.Top,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.showAge,
|
||||
onCheckedChange = viewModel::setSpecialDatesShowAge,
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.setSpecialDatesShowAge(!state.showAge) },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_special_dates_sync_now),
|
||||
summary = specialDatesLastRunLabel(context, state.lastRun),
|
||||
position = Position.Bottom,
|
||||
onClick = viewModel::syncSpecialDatesNow,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.settings_special_dates_calendar_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmDisableAll) {
|
||||
SpecialDatesDisableDialog(
|
||||
message = stringResource(R.string.settings_special_dates_disable_all_message),
|
||||
onConfirm = { viewModel.setSpecialDatesEnabled(false); confirmDisableAll = false },
|
||||
onDismiss = { confirmDisableAll = false },
|
||||
)
|
||||
}
|
||||
confirmDisableType?.let { type ->
|
||||
SpecialDatesDisableDialog(
|
||||
message = stringResource(
|
||||
R.string.settings_special_dates_disable_type_message,
|
||||
stringResource(specialDateTypeLabel(type)),
|
||||
),
|
||||
onConfirm = { viewModel.setSpecialDateTypeEnabled(type, false); confirmDisableType = null },
|
||||
onDismiss = { confirmDisableType = null },
|
||||
)
|
||||
}
|
||||
editTemplate?.let { type ->
|
||||
SpecialDatesTemplateDialog(
|
||||
initial = state.titleTemplates[type].orEmpty(),
|
||||
onConfirm = { viewModel.setSpecialDatesTitleTemplate(type, it); editTemplate = null },
|
||||
onDismiss = { editTemplate = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SpecialDatesDisableDialog(
|
||||
message: String,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.settings_special_dates_disable_title)) },
|
||||
text = { Text(message) },
|
||||
confirmButton = {
|
||||
androidx.compose.material3.TextButton(onClick = onConfirm) {
|
||||
Text(stringResource(R.string.settings_special_dates_disable_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
androidx.compose.material3.TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.dialog_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SpecialDatesTemplateDialog(
|
||||
initial: String,
|
||||
onConfirm: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var text by rememberSaveable { mutableStateOf(initial) }
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.settings_special_dates_template)) },
|
||||
text = {
|
||||
Column {
|
||||
androidx.compose.material3.OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.settings_special_dates_template_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
androidx.compose.material3.TextButton(
|
||||
onClick = { onConfirm(text) },
|
||||
enabled = text.isNotBlank(),
|
||||
) { Text(stringResource(R.string.dialog_save)) }
|
||||
},
|
||||
dismissButton = {
|
||||
androidx.compose.material3.TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.dialog_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun specialDateTypeLabel(type: SpecialDateType): Int = when (type) {
|
||||
SpecialDateType.Birthday -> R.string.settings_special_dates_type_birthday
|
||||
SpecialDateType.Anniversary -> R.string.settings_special_dates_type_anniversary
|
||||
SpecialDateType.Custom -> R.string.settings_special_dates_type_custom
|
||||
}
|
||||
|
||||
private fun hasContactsPermission(context: Context): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
@Composable
|
||||
private fun specialDatesLastRunLabel(context: Context, lastRun: Long): String =
|
||||
if (lastRun <= 0L) {
|
||||
stringResource(R.string.settings_special_dates_never_synced)
|
||||
} else {
|
||||
stringResource(
|
||||
R.string.settings_special_dates_last_synced,
|
||||
android.text.format.DateUtils.getRelativeTimeSpanString(
|
||||
lastRun,
|
||||
System.currentTimeMillis(),
|
||||
android.text.format.DateUtils.MINUTE_IN_MILLIS,
|
||||
).toString(),
|
||||
)
|
||||
}
|
||||
|
||||
/** Which calendar + event kind a per-calendar reminder-override dialog targets. */
|
||||
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
|
||||
@@ -70,3 +71,20 @@ data class SettingsUiState(
|
||||
*/
|
||||
val allowColorOnUnsupportedCalendars: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* State for the contact special-dates sub-page (issue #15). Exposed as its own
|
||||
* flow rather than folded into [SettingsUiState] (whose combine is already full).
|
||||
* [titleTemplates] is always populated — a blank stored template resolves to the
|
||||
* localized default so the editor never shows an empty field.
|
||||
*/
|
||||
data class SpecialDatesUiState(
|
||||
val enabled: Boolean = false,
|
||||
val types: Set<SpecialDateType> = SpecialDateType.entries.toSet(),
|
||||
val titleTemplates: Map<SpecialDateType, String> = emptyMap(),
|
||||
val showAge: Boolean = true,
|
||||
/** True when READ_CONTACTS was revoked after enabling — the mirror is paused. */
|
||||
val stalledPermission: Boolean = false,
|
||||
/** Epoch millis of the last sync, or 0 if it has never run. */
|
||||
val lastRun: Long = 0L,
|
||||
)
|
||||
|
||||
@@ -10,14 +10,22 @@ import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncEngine
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
@@ -41,6 +49,9 @@ import javax.inject.Inject
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val prefs: SettingsPrefs,
|
||||
repository: CalendarRepository,
|
||||
private val specialDatesEngine: SpecialDatesSyncEngine,
|
||||
specialDatesSpec: SpecialDatesCalendarSpec,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
@ApplicationContext private val appContext: Context,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -166,6 +177,77 @@ class SettingsViewModel @Inject constructor(
|
||||
val dimCompletedEvents: Boolean,
|
||||
)
|
||||
|
||||
/** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */
|
||||
val specialDatesState: StateFlow<SpecialDatesUiState> = combine(
|
||||
prefs.specialDatesEnabled,
|
||||
prefs.specialDatesTypes,
|
||||
prefs.specialDatesTitleTemplates,
|
||||
prefs.specialDatesShowAge,
|
||||
prefs.specialDatesStatus,
|
||||
) { enabled, types, templates, showAge, status ->
|
||||
SpecialDatesUiState(
|
||||
enabled = enabled,
|
||||
types = types,
|
||||
// A blank stored template resolves to the localized default so the
|
||||
// editor always shows something sensible.
|
||||
titleTemplates = SpecialDateType.entries.associateWith { type ->
|
||||
templates[type]?.takeIf { it.isNotBlank() }
|
||||
?: specialDatesSpec.defaultTitleTemplate(type)
|
||||
},
|
||||
showAge = showAge,
|
||||
stalledPermission = status.stalled == SpecialDatesStalledReason.PermissionRevoked,
|
||||
lastRun = status.lastRun,
|
||||
)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = SpecialDatesUiState(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Turn the mirror on or off. Enabling schedules the daily job and runs an
|
||||
* immediate sync (which creates the calendars); disabling cancels the job and
|
||||
* tears the managed calendars down. The caller must have READ_CONTACTS granted
|
||||
* before enabling.
|
||||
*/
|
||||
fun setSpecialDatesEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.setSpecialDatesEnabled(enabled)
|
||||
if (enabled) {
|
||||
SpecialDatesScheduler.apply(appContext, enabled = true)
|
||||
SpecialDatesScheduler.runNow(appContext)
|
||||
} else {
|
||||
SpecialDatesScheduler.apply(appContext, enabled = false)
|
||||
withContext(io) { specialDatesEngine.teardown() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setSpecialDateTypeEnabled(type: SpecialDateType, enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.setSpecialDateTypeEnabled(type, enabled)
|
||||
// Reconcile now: create the new type's calendar, or delete a disabled one.
|
||||
SpecialDatesScheduler.runNow(appContext)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSpecialDatesTitleTemplate(type: SpecialDateType, template: String) {
|
||||
viewModelScope.launch {
|
||||
prefs.setSpecialDatesTitleTemplate(type, template.trim())
|
||||
SpecialDatesScheduler.runNow(appContext)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSpecialDatesShowAge(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.setSpecialDatesShowAge(enabled)
|
||||
SpecialDatesScheduler.runNow(appContext)
|
||||
}
|
||||
}
|
||||
|
||||
/** Kick off an immediate reconcile (the "Sync now" action). */
|
||||
fun syncSpecialDatesNow() = SpecialDatesScheduler.runNow(appContext)
|
||||
|
||||
fun setThemeMode(mode: ThemeMode) {
|
||||
viewModelScope.launch { prefs.setThemeMode(mode) }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user