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.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.BugReport
|
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.CalendarMonth
|
||||||
import androidx.compose.material.icons.filled.Dashboard
|
import androidx.compose.material.icons.filled.Dashboard
|
||||||
import androidx.compose.material.icons.filled.Check
|
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.TimeFormatPref
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker
|
import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker
|
||||||
@@ -121,7 +123,7 @@ import java.time.format.TextStyle as JavaTextStyle
|
|||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
|
|
||||||
/** The settings sub-screens reached from the hub's category rows. */
|
/** 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).
|
* 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 })
|
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) },
|
leading = { CategoryIcon(Icons.Default.CalendarMonth, ChipAccent.Tertiary) },
|
||||||
onClick = onManageCalendars,
|
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)
|
LanguageRow(position = Position.Middle)
|
||||||
// One-tap add of the "New event" Quick Settings tile. The system prompt
|
// 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
|
// 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. */
|
/** Which calendar + event kind a per-calendar reminder-override dialog targets. */
|
||||||
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
|
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.data.prefs.WeekStartPref
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
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.agenda.AgendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
|
||||||
@@ -70,3 +71,20 @@ data class SettingsUiState(
|
|||||||
*/
|
*/
|
||||||
val allowColorOnUnsupportedCalendars: Boolean = false,
|
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.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
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.CalendarReminderOverride
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
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.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
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.AgendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
@@ -41,6 +49,9 @@ import javax.inject.Inject
|
|||||||
class SettingsViewModel @Inject constructor(
|
class SettingsViewModel @Inject constructor(
|
||||||
private val prefs: SettingsPrefs,
|
private val prefs: SettingsPrefs,
|
||||||
repository: CalendarRepository,
|
repository: CalendarRepository,
|
||||||
|
private val specialDatesEngine: SpecialDatesSyncEngine,
|
||||||
|
specialDatesSpec: SpecialDatesCalendarSpec,
|
||||||
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
@ApplicationContext private val appContext: Context,
|
@ApplicationContext private val appContext: Context,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -166,6 +177,77 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val dimCompletedEvents: Boolean,
|
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) {
|
fun setThemeMode(mode: ThemeMode) {
|
||||||
viewModelScope.launch { prefs.setThemeMode(mode) }
|
viewModelScope.launch { prefs.setThemeMode(mode) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -346,6 +346,33 @@
|
|||||||
<string name="settings_appearance_subtitle">Theme, default view, week start</string>
|
<string name="settings_appearance_subtitle">Theme, default view, week start</string>
|
||||||
<string name="settings_event_form_subtitle">Default fields for new events</string>
|
<string name="settings_event_form_subtitle">Default fields for new events</string>
|
||||||
<string name="settings_notifications_subtitle">Event reminders</string>
|
<string name="settings_notifications_subtitle">Event reminders</string>
|
||||||
|
<string name="settings_special_dates_subtitle">Contact birthdays & anniversaries</string>
|
||||||
|
|
||||||
|
<!-- Contact special dates (issue #15) -->
|
||||||
|
<string name="settings_section_special_dates">Contact special dates</string>
|
||||||
|
<string name="settings_special_dates_enable">Show contact dates</string>
|
||||||
|
<string name="settings_special_dates_enable_hint">Mirror your contacts\' birthdays and other dates into local calendars. Reads contacts on this device only — nothing is uploaded, and your contacts are never changed.</string>
|
||||||
|
<string name="settings_special_dates_type_birthday">Birthdays</string>
|
||||||
|
<string name="settings_special_dates_type_anniversary">Anniversaries</string>
|
||||||
|
<string name="settings_special_dates_type_custom">Other dates</string>
|
||||||
|
<string name="settings_special_dates_template">Title format</string>
|
||||||
|
<string name="settings_special_dates_template_hint">Use {name} for the contact and {age} for their age (hidden when the year is unknown).</string>
|
||||||
|
<string name="settings_special_dates_show_age">Show age</string>
|
||||||
|
<string name="settings_special_dates_show_age_hint">Include {age} in titles when the birth year is known</string>
|
||||||
|
<string name="settings_special_dates_sync_now">Sync now</string>
|
||||||
|
<string name="settings_special_dates_never_synced">Not synced yet</string>
|
||||||
|
<!-- %1$s is a relative time, e.g. "5 minutes ago". -->
|
||||||
|
<string name="settings_special_dates_last_synced">Last synced %1$s</string>
|
||||||
|
<string name="settings_special_dates_calendar_hint">Set each calendar\'s colour, visibility and reminders in Calendars settings.</string>
|
||||||
|
<string name="settings_special_dates_paused_title">Paused</string>
|
||||||
|
<string name="settings_special_dates_paused_hint">Calendula can no longer read your contacts, so these calendars aren\'t updating.</string>
|
||||||
|
<string name="settings_special_dates_grant">Grant access</string>
|
||||||
|
<string name="settings_special_dates_disable_title">Turn off contact dates?</string>
|
||||||
|
<string name="settings_special_dates_disable_all_message">This deletes the contact-date calendars and their events. Any reminders or notes you added to them will be lost.</string>
|
||||||
|
<!-- %1$s is the date type, e.g. "Birthdays". -->
|
||||||
|
<string name="settings_special_dates_disable_type_message">This deletes the “%1$s” calendar and its events. Any reminders or notes you added to them will be lost.</string>
|
||||||
|
<string name="settings_special_dates_disable_confirm">Turn off</string>
|
||||||
|
<string name="dialog_save">Save</string>
|
||||||
<string name="settings_section_about">About</string>
|
<string name="settings_section_about">About</string>
|
||||||
<string name="settings_license">License</string>
|
<string name="settings_license">License</string>
|
||||||
<string name="settings_license_value">MIT</string>
|
<string name="settings_license_value">MIT</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user