Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions android/app/src/main/java/com/github/quarck/calnotify/Settings.kt
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,21 @@ class Settings(context: Context) : PersistentStorageBase(context), SettingsInter
.toLongArray()
}

/** Raw configurable snoozed-until filter presets string (e.g., "12h, 1d, 3d, 7d, 4w") */
val snoozedUntilPresetsRaw: String
get() = getString(SNOOZED_UNTIL_PRESETS_KEY, DEFAULT_SNOOZED_UNTIL_PRESETS)

/** Parsed snoozed-until filter presets in milliseconds. Filters out negative values. */
val snoozedUntilPresets: LongArray
get() {
val ret = PreferenceUtils.parseSnoozePresets(snoozedUntilPresetsRaw)
?: PreferenceUtils.parseSnoozePresets(DEFAULT_SNOOZED_UNTIL_PRESETS)
?: return longArrayOf()
return ret.filter { it > 0 }
.take(MAX_SNOOZED_UNTIL_PRESETS)
.toLongArray()
}

/** Max calendars to show in calendar filter. 0 = no limit (show all). */
val calendarFilterMaxItems: Int
get() = getString(CALENDAR_FILTER_MAX_ITEMS_KEY, DEFAULT_CALENDAR_FILTER_MAX_ITEMS.toString())
Expand Down Expand Up @@ -613,6 +628,7 @@ class Settings(context: Context) : PersistentStorageBase(context), SettingsInter
private const val UPCOMING_EVENTS_FIXED_HOURS_KEY = "upcoming_events_fixed_hours"
private const val UPCOMING_FIXED_LOOKAHEAD_MILLIS_KEY = "upcoming_fixed_lookahead_millis"
private const val UPCOMING_TIME_PRESETS_KEY = "pref_upcoming_time_presets"
private const val SNOOZED_UNTIL_PRESETS_KEY = "pref_snoozed_until_presets"
private const val CALENDAR_FILTER_MAX_ITEMS_KEY = "calendar_filter_max_items"
private const val CALENDAR_FILTER_SHOW_SEARCH_KEY = "calendar_filter_show_search"
private const val CALENDAR_FILTER_SHOW_IDS_KEY = "calendar_filter_show_ids"
Expand All @@ -638,6 +654,8 @@ class Settings(context: Context) : PersistentStorageBase(context), SettingsInter
internal const val MAX_UPCOMING_TIME_PRESETS = 10
internal const val MAX_LOOKAHEAD_DAYS = 30L
internal const val MAX_LOOKAHEAD_MILLIS = MAX_LOOKAHEAD_DAYS * Consts.DAY_IN_MILLISECONDS
internal const val DEFAULT_SNOOZED_UNTIL_PRESETS = "12h, 1d, 3d, 7d, 4w"
internal const val MAX_SNOOZED_UNTIL_PRESETS = 10
/** Default max calendars in filter (0 = no limit) */
internal const val DEFAULT_CALENDAR_FILTER_MAX_ITEMS = 20
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,20 @@ class NavigationSettingsFragmentX : PreferenceFragmentCompat() {
}

override fun onDisplayPreferenceDialog(preference: Preference) {
if (preference is UpcomingTimePresetPreferenceX) {
val dialogFragment = UpcomingTimePresetPreferenceX.Dialog.newInstance(preference.key)
@Suppress("DEPRECATION")
dialogFragment.setTargetFragment(this, 0)
dialogFragment.show(parentFragmentManager, DIALOG_FRAGMENT_TAG)
} else {
super.onDisplayPreferenceDialog(preference)
when (preference) {
is UpcomingTimePresetPreferenceX -> {
val dialogFragment = UpcomingTimePresetPreferenceX.Dialog.newInstance(preference.key)
@Suppress("DEPRECATION")
dialogFragment.setTargetFragment(this, 0)
dialogFragment.show(parentFragmentManager, DIALOG_FRAGMENT_TAG)
}
is SnoozedUntilPresetPreferenceX -> {
val dialogFragment = SnoozedUntilPresetPreferenceX.Dialog.newInstance(preference.key)
@Suppress("DEPRECATION")
dialogFragment.setTargetFragment(this, 0)
dialogFragment.show(parentFragmentManager, DIALOG_FRAGMENT_TAG)
}
else -> super.onDisplayPreferenceDialog(preference)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
//
// Calendar Notifications Plus
// Copyright (C) 2025 William Harris (wharris+cnplus@upscalews.com)
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation; either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software Foundation,
// Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
//

package com.github.quarck.calnotify.prefs

import android.app.AlertDialog
import android.content.Context
import android.os.Bundle
import android.util.AttributeSet
import android.view.View
import android.widget.EditText
import android.widget.TextView
import androidx.preference.DialogPreference
import androidx.preference.PreferenceDialogFragmentCompat
import com.github.quarck.calnotify.R
import com.github.quarck.calnotify.Settings

class SnoozedUntilPresetPreferenceX @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = androidx.preference.R.attr.dialogPreferenceStyle,
defStyleRes: Int = 0
) : DialogPreference(context, attrs, defStyleAttr, defStyleRes) {

var presetValue: String = Settings.DEFAULT_SNOOZED_UNTIL_PRESETS
private set

init {
dialogLayoutResource = R.layout.dialog_snoozed_until_presets
positiveButtonText = context.getString(android.R.string.ok)
negativeButtonText = context.getString(android.R.string.cancel)
}

fun persistPreset(value: String) {
presetValue = value
persistString(value)
notifyChanged()
}

override fun onSetInitialValue(defaultValue: Any?) {
presetValue = getPersistedString((defaultValue as? String) ?: Settings.DEFAULT_SNOOZED_UNTIL_PRESETS)
}

override fun onGetDefaultValue(a: android.content.res.TypedArray, index: Int): Any? {
return a.getString(index)
}

class Dialog : PreferenceDialogFragmentCompat() {
private var edit: EditText? = null

override fun onBindDialogView(view: View) {
super.onBindDialogView(view)

val pref = preference as SnoozedUntilPresetPreferenceX

val label = view.findViewById<TextView>(R.id.text_label_snoozed_until_presets)
label?.text = getString(R.string.dialog_snoozed_until_presets_label, Settings.MAX_SNOOZED_UNTIL_PRESETS)

edit = view.findViewById(R.id.edit_text_snoozed_until_presets)
edit?.setText(pref.presetValue)
}

override fun onDialogClosed(positiveResult: Boolean) {
if (!positiveResult) return
val value = edit?.text?.toString() ?: return

val result = PreferenceUtils.normalizePresetInput(
value, Settings.DEFAULT_SNOOZED_UNTIL_PRESETS
) { it > 0 }

if (result != null) {
val pref = preference as SnoozedUntilPresetPreferenceX
if (pref.callChangeListener(result.value)) {
pref.persistPreset(result.value)
}

if (result.droppedCount > 0) {
showMessage(R.string.warning_presets_invalid_removed)
}

val parsed = PreferenceUtils.parseSnoozePresets(result.value)
if (parsed != null && parsed.size > Settings.MAX_SNOOZED_UNTIL_PRESETS) {
showFormattedMessage(R.string.error_too_many_snoozed_until_presets, Settings.MAX_SNOOZED_UNTIL_PRESETS)
}
} else {
showMessage(R.string.error_cannot_parse_preset)
}
}

private fun showMessage(id: Int) {
val context = requireContext()
AlertDialog.Builder(context)
.setMessage(context.getString(id))
.setCancelable(false)
.setPositiveButton(android.R.string.ok) { _, _ -> }
.create()
.show()
}

private fun showFormattedMessage(id: Int, vararg args: Any) {
val context = requireContext()
AlertDialog.Builder(context)
.setMessage(context.getString(id, *args))
.setCancelable(false)
.setPositiveButton(android.R.string.ok) { _, _ -> }
.create()
.show()
}

companion object {
fun newInstance(key: String): Dialog {
val fragment = Dialog()
val args = Bundle(1)
args.putString(ARG_KEY, key)
fragment.arguments = args
return fragment
}
}
}
}
Comment thread
williscool marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,53 @@ import com.github.quarck.calnotify.utils.DateTimeUtils
* Which filters to apply when filtering events.
*/
enum class FilterType {
CALENDAR, STATUS, TIME
CALENDAR, STATUS, TIME, SNOOZED_UNTIL
}

/**
* Direction for the snoozed-until filter threshold.
*/
enum class FilterDirection {
BEFORE, // snoozedUntil <= threshold
AFTER // snoozedUntil > threshold
}

/**
* Mode for the snoozed-until filter.
*/
enum class SnoozedUntilFilterMode {
ALL, // no filter (default)
PRESET, // from configurable interval presets; valueMillis = duration
CUSTOM_PERIOD, // user-entered duration; valueMillis = duration
SPECIFIC_TIME // user-picked date+time; valueMillis = absolute timestamp
}

/**
* Configuration for the snoozed-until filter. Unlike the simple TimeFilter enum,
* this supports multiple modes (presets, custom duration, specific time) and a
* before/after direction toggle.
*/
data class SnoozedUntilFilterConfig(
val mode: SnoozedUntilFilterMode = SnoozedUntilFilterMode.ALL,
val direction: FilterDirection = FilterDirection.BEFORE,
val valueMillis: Long = 0L,
val includeUnsnoozed: Boolean = false
) {
fun matches(event: EventAlertRecord, now: Long): Boolean {
if (mode == SnoozedUntilFilterMode.ALL) return true
if (event.snoozedUntil == 0L) return includeUnsnoozed

val threshold = when (mode) {
SnoozedUntilFilterMode.ALL -> return true
SnoozedUntilFilterMode.PRESET, SnoozedUntilFilterMode.CUSTOM_PERIOD -> now + valueMillis
SnoozedUntilFilterMode.SPECIFIC_TIME -> valueMillis
}

return when (direction) {
FilterDirection.BEFORE -> event.snoozedUntil <= threshold
FilterDirection.AFTER -> event.snoozedUntil > threshold
}
}
}

/**
Expand All @@ -39,14 +85,19 @@ enum class FilterType {
data class FilterState(
val selectedCalendarIds: Set<Long>? = null, // null = no filter (all), empty = none, set = specific
val statusFilters: Set<StatusOption> = emptySet(), // empty = show all (no filter)
val timeFilter: TimeFilter = TimeFilter.ALL
val timeFilter: TimeFilter = TimeFilter.ALL,
val snoozedUntilFilter: SnoozedUntilFilterConfig = SnoozedUntilFilterConfig()
) {

companion object {
private const val BUNDLE_CALENDAR_IDS = "filter_calendar_ids"
private const val BUNDLE_CALENDAR_NULL = "filter_calendar_null"
private const val BUNDLE_STATUS_FILTERS = "filter_status"
private const val BUNDLE_TIME_FILTER = "filter_time"
private const val BUNDLE_SNOOZED_UNTIL_MODE = "filter_snoozed_until_mode"
private const val BUNDLE_SNOOZED_UNTIL_DIRECTION = "filter_snoozed_until_direction"
private const val BUNDLE_SNOOZED_UNTIL_VALUE = "filter_snoozed_until_value"
private const val BUNDLE_SNOOZED_UNTIL_INCLUDE_UNSNOOZED = "filter_snoozed_until_include_unsnoozed"

/** Deserialize FilterState from a Bundle */
fun fromBundle(bundle: Bundle?): FilterState {
Expand All @@ -67,10 +118,22 @@ data class FilterState(
bundle.getInt(BUNDLE_TIME_FILTER, 0)
) ?: TimeFilter.ALL

val snoozedUntilFilter = SnoozedUntilFilterConfig(
mode = SnoozedUntilFilterMode.entries.getOrNull(
bundle.getInt(BUNDLE_SNOOZED_UNTIL_MODE, 0)
) ?: SnoozedUntilFilterMode.ALL,
direction = FilterDirection.entries.getOrNull(
bundle.getInt(BUNDLE_SNOOZED_UNTIL_DIRECTION, 0)
) ?: FilterDirection.BEFORE,
valueMillis = bundle.getLong(BUNDLE_SNOOZED_UNTIL_VALUE, 0L),
includeUnsnoozed = bundle.getBoolean(BUNDLE_SNOOZED_UNTIL_INCLUDE_UNSNOOZED, false)
)

return FilterState(
selectedCalendarIds = calendarIds,
statusFilters = statusFilters,
timeFilter = timeFilter
timeFilter = timeFilter,
snoozedUntilFilter = snoozedUntilFilter
)
}
}
Expand All @@ -83,13 +146,18 @@ data class FilterState(

putIntArray(BUNDLE_STATUS_FILTERS, statusFilters.map { it.ordinal }.toIntArray())
putInt(BUNDLE_TIME_FILTER, timeFilter.ordinal)
putInt(BUNDLE_SNOOZED_UNTIL_MODE, snoozedUntilFilter.mode.ordinal)
putInt(BUNDLE_SNOOZED_UNTIL_DIRECTION, snoozedUntilFilter.direction.ordinal)
putLong(BUNDLE_SNOOZED_UNTIL_VALUE, snoozedUntilFilter.valueMillis)
putBoolean(BUNDLE_SNOOZED_UNTIL_INCLUDE_UNSNOOZED, snoozedUntilFilter.includeUnsnoozed)
}

/** Check if any filters are active */
fun hasActiveFilters(): Boolean {
return selectedCalendarIds != null ||
statusFilters.isNotEmpty() ||
timeFilter != TimeFilter.ALL
timeFilter != TimeFilter.ALL ||
snoozedUntilFilter.mode != SnoozedUntilFilterMode.ALL
}

/**
Expand Down Expand Up @@ -130,6 +198,15 @@ data class FilterState(
TimeFilter.STARTED_THIS_MONTH -> parts.add(context.getString(R.string.filter_time_started_this_month))
}

// Snoozed until filter
if (snoozedUntilFilter.mode != SnoozedUntilFilterMode.ALL) {
val symbol = when (snoozedUntilFilter.direction) {
FilterDirection.BEFORE -> "\u2264" // ≤
FilterDirection.AFTER -> ">"
}
parts.add(context.getString(R.string.filter_snoozed_until_display, symbol))
}

return if (parts.isEmpty()) null else parts.joinToString(", ")
}
/** Check if an event matches current status filters (empty set = match all) */
Expand All @@ -143,6 +220,11 @@ data class FilterState(
return timeFilter.matches(event, now)
}

/** Check if an event matches current snoozed-until filter */
fun matchesSnoozedUntil(event: EventAlertRecord, now: Long): Boolean {
return snoozedUntilFilter.matches(event, now)
}

/** Check if an event matches current calendar filter (null = all, empty = none) */
fun matchesCalendar(event: EventAlertRecord): Boolean {
if (selectedCalendarIds == null) return true // No filter = show all
Expand All @@ -165,15 +247,16 @@ data class FilterState(
val event = eventExtractor(item)
(FilterType.CALENDAR !in apply || matchesCalendar(event)) &&
(FilterType.STATUS !in apply || matchesStatus(event)) &&
(FilterType.TIME !in apply || matchesTime(event, now))
(FilterType.TIME !in apply || matchesTime(event, now)) &&
(FilterType.SNOOZED_UNTIL !in apply || matchesSnoozedUntil(event, now))
}.toTypedArray()
}

/** Filter EventAlertRecord list with specified filters (defaults to all) */
fun filterEvents(
events: List<EventAlertRecord>,
now: Long,
apply: Set<FilterType> = setOf(FilterType.CALENDAR, FilterType.STATUS, FilterType.TIME)
apply: Set<FilterType> = setOf(FilterType.CALENDAR, FilterType.STATUS, FilterType.TIME, FilterType.SNOOZED_UNTIL)
): Array<EventAlertRecord> {
return filterEvents(events, now, apply) { it }
}
Expand Down
Loading
Loading