diff --git a/android/app/src/androidTest/java/com/github/quarck/calnotify/app/ApplicationControllerCoreTest.kt b/android/app/src/androidTest/java/com/github/quarck/calnotify/app/ApplicationControllerCoreTest.kt index 653691a8..f1d01453 100644 --- a/android/app/src/androidTest/java/com/github/quarck/calnotify/app/ApplicationControllerCoreTest.kt +++ b/android/app/src/androidTest/java/com/github/quarck/calnotify/app/ApplicationControllerCoreTest.kt @@ -341,5 +341,86 @@ class ApplicationControllerCoreTest { } assertFalse("Task event should NOT be muted", updatedEvent?.isMuted == true) } + + // === muteAllVisibleEvents does NOT skip pinned (pinning only affects batch snooze) === + + @Test + fun testMuteAllVisibleEvents_mutesPinnedEvents() { + DevLog.info(LOG_TAG, "Running testMuteAllVisibleEvents_mutesPinnedEvents") + + val pinnedEventId = 100040L + val normalEventId = 100041L + val pinnedEvent = createTestEvent(eventId = pinnedEventId, isMuted = false).apply { isPinned = true } + val normalEvent = createTestEvent(eventId = normalEventId, isMuted = false) + EventsStorage(context).use { db -> + db.addEvent(pinnedEvent) + db.addEvent(normalEvent) + } + + ApplicationController.muteAllVisibleEvents(context) + + EventsStorage(context).use { db -> + val updatedPinned = db.getEvent(pinnedEventId, baseTime) + val updatedNormal = db.getEvent(normalEventId, baseTime) + assertTrue("Pinned event should be muted (pinning only affects batch snooze)", updatedPinned?.isMuted == true) + assertTrue("Normal event should be muted", updatedNormal?.isMuted == true) + } + } + + // === pinAllVisibleEvents / unpinAllVisibleEvents === + + @Test + fun testPinAllVisibleEvents_pinsVisibleEvents() { + DevLog.info(LOG_TAG, "Running testPinAllVisibleEvents_pinsVisibleEvents") + + val eventId = 100042L + val event = createTestEvent(eventId = eventId) + EventsStorage(context).use { db -> + db.addEvent(event) + } + + ApplicationController.pinAllVisibleEvents(context) + + val updatedEvent = EventsStorage(context).use { db -> + db.getEvent(eventId, baseTime) + } + assertTrue("Visible event should be pinned", updatedEvent?.isPinned == true) + } + + @Test + fun testPinAllVisibleEvents_skipsSnoozedEvents() { + DevLog.info(LOG_TAG, "Running testPinAllVisibleEvents_skipsSnoozedEvents") + + val eventId = 100043L + val event = createTestEvent(eventId = eventId, snoozedUntil = baseTime + 3600000L) + EventsStorage(context).use { db -> + db.addEvent(event) + } + + ApplicationController.pinAllVisibleEvents(context) + + val updatedEvent = EventsStorage(context).use { db -> + db.getEvent(eventId, baseTime) + } + assertFalse("Snoozed event should NOT be pinned", updatedEvent?.isPinned == true) + } + + @Test + fun testUnpinAllVisibleEvents_unpinsAll() { + DevLog.info(LOG_TAG, "Running testUnpinAllVisibleEvents_unpinsAll") + + val eventId = 100044L + val event = createTestEvent(eventId = eventId).apply { isPinned = true } + EventsStorage(context).use { db -> + db.addEvent(event) + } + + ApplicationController.unpinAllVisibleEvents(context) + + val updatedEvent = EventsStorage(context).use { db -> + db.getEvent(eventId, baseTime) + } + assertFalse("Event should be unpinned", updatedEvent?.isPinned == true) + } } diff --git a/android/app/src/androidTest/java/com/github/quarck/calnotify/app/SnoozeTest.kt b/android/app/src/androidTest/java/com/github/quarck/calnotify/app/SnoozeTest.kt index a5fec675..9bbd43ef 100644 --- a/android/app/src/androidTest/java/com/github/quarck/calnotify/app/SnoozeTest.kt +++ b/android/app/src/androidTest/java/com/github/quarck/calnotify/app/SnoozeTest.kt @@ -376,5 +376,65 @@ class SnoozeTest { assertTrue("Not snoozed event should be snoozed", updatedNot!!.snoozedUntil > 0) } } + + // === Pinned exclusion from snoozeAll === + + @Test + fun testSnoozeAllEvents_skipsPinnedEvents() { + DevLog.info(LOG_TAG, "Running testSnoozeAllEvents_skipsPinnedEvents") + + val pinnedEvent = createTestEvent(eventId = 600L, title = "Pinned").apply { isPinned = true } + val normalEvent = createTestEvent(eventId = 601L, title = "Normal") + addEventToStorage(pinnedEvent) + addEventToStorage(normalEvent) + + val snoozeDelay = 30 * 60 * 1000L + + val result = ApplicationController.snoozeAllEvents( + context, snoozeDelay, isChange = false, onlySnoozeVisible = false + ) + + assertNotNull("Snooze result should not be null", result) + + EventsStorage(context).use { db -> + val updatedPinned = db.getEvent(pinnedEvent.eventId, pinnedEvent.instanceStartTime) + val updatedNormal = db.getEvent(normalEvent.eventId, normalEvent.instanceStartTime) + + assertEquals("Pinned event should NOT be snoozed", 0L, updatedPinned!!.snoozedUntil) + assertTrue("Normal event should be snoozed", updatedNormal!!.snoozedUntil > 0) + } + } + + @Test + fun testSnoozeAllCollapsedEvents_skipsPinnedEvents() { + DevLog.info(LOG_TAG, "Running testSnoozeAllCollapsedEvents_skipsPinnedEvents") + + val pinnedCollapsed = createTestEvent( + eventId = 700L, + displayStatus = EventDisplayStatus.DisplayedCollapsed + ).apply { isPinned = true } + val normalCollapsed = createTestEvent( + eventId = 701L, + displayStatus = EventDisplayStatus.DisplayedCollapsed + ) + addEventToStorage(pinnedCollapsed) + addEventToStorage(normalCollapsed) + + val snoozeDelay = 30 * 60 * 1000L + + val result = ApplicationController.snoozeAllCollapsedEvents( + context, snoozeDelay, isChange = false, onlySnoozeVisible = false + ) + + assertNotNull("Snooze result should not be null", result) + + EventsStorage(context).use { db -> + val updatedPinned = db.getEvent(pinnedCollapsed.eventId, pinnedCollapsed.instanceStartTime) + val updatedNormal = db.getEvent(normalCollapsed.eventId, normalCollapsed.instanceStartTime) + + assertEquals("Pinned collapsed event should NOT be snoozed", 0L, updatedPinned!!.snoozedUntil) + assertTrue("Normal collapsed event should be snoozed", updatedNormal!!.snoozedUntil > 0) + } + } } diff --git a/android/app/src/main/java/com/github/quarck/calnotify/Consts.kt b/android/app/src/main/java/com/github/quarck/calnotify/Consts.kt index e3fcf22b..0039f523 100644 --- a/android/app/src/main/java/com/github/quarck/calnotify/Consts.kt +++ b/android/app/src/main/java/com/github/quarck/calnotify/Consts.kt @@ -73,6 +73,7 @@ object Consts { const val INTENT_SEARCH_QUERY = "search_query" const val INTENT_SEARCH_QUERY_EVENT_COUNT = "search_query_event_count" const val INTENT_FILTER_STATE = "filter_state" + const val INTENT_PINNED_EVENT_COUNT = "pinned_event_count" const val INTENT_SNOOZE_ALL_COLLAPSED_KEY = "snooze_all_collapsed" const val INTENT_DISMISS_ALL_KEY = "dismiss_all" diff --git a/android/app/src/main/java/com/github/quarck/calnotify/app/ApplicationController.kt b/android/app/src/main/java/com/github/quarck/calnotify/app/ApplicationController.kt index fe7648f9..fcae9937 100644 --- a/android/app/src/main/java/com/github/quarck/calnotify/app/ApplicationController.kt +++ b/android/app/src/main/java/com/github/quarck/calnotify/app/ApplicationController.kt @@ -930,7 +930,7 @@ object ApplicationController : ApplicationControllerInterface, EventMovedHandler } fun snoozeAllCollapsedEvents(context: Context, snoozeDelay: Long, isChange: Boolean, onlySnoozeVisible: Boolean): SnoozeResult? { - return snoozeEvents(context, { it.displayStatus == EventDisplayStatus.DisplayedCollapsed }, snoozeDelay, isChange, onlySnoozeVisible) + return snoozeEvents(context, { it.displayStatus == EventDisplayStatus.DisplayedCollapsed && !it.isPinned }, snoozeDelay, isChange, onlySnoozeVisible) } fun snoozeAllEvents( @@ -943,20 +943,19 @@ object ApplicationController : ApplicationControllerInterface, EventMovedHandler ): SnoozeResult? { val now = clock.currentTimeMillis() return snoozeEvents(context, { event -> + // Pinned events are excluded from batch snooze + !event.isPinned && // Search query filter (existing behavior) - val matchesSearch = searchQuery?.let { query -> + (searchQuery?.let { query -> event.title.contains(query, ignoreCase = true) || event.desc.contains(query, ignoreCase = true) - } ?: true - - // FilterState filters (new) - val matchesFilter = filterState?.let { filter -> + } ?: true) && + // FilterState filters + (filterState?.let { filter -> filter.matchesCalendar(event) && filter.matchesStatus(event) && filter.matchesTime(event, now) - } ?: true - - matchesSearch && matchesFilter + } ?: true) }, snoozeDelay, isChange, onlySnoozeVisible) } @@ -1147,6 +1146,28 @@ object ApplicationController : ApplicationControllerInterface, EventMovedHandler } } + fun pinAllVisibleEvents(context: Context) { + getEventsStorage(context).use { db -> + val eventsToPin = db.events.filter { + event -> (event.snoozedUntil == 0L) && event.isNotSpecial && !event.isPinned + } + if (eventsToPin.isNotEmpty()) { + val pinnedEvents = eventsToPin.map { it.isPinned = true; it } + db.updateEvents(pinnedEvents) + } + } + } + + fun unpinAllVisibleEvents(context: Context) { + getEventsStorage(context).use { db -> + val eventsToUnpin = db.events.filter { event -> event.isPinned } + if (eventsToUnpin.isNotEmpty()) { + val unpinnedEvents = eventsToUnpin.map { it.isPinned = false; it } + db.updateEvents(unpinnedEvents) + } + } + } + fun dismissEvent( context: Context, db: EventsStorageInterface, diff --git a/android/app/src/main/java/com/github/quarck/calnotify/calendar/EventAlertRecord.kt b/android/app/src/main/java/com/github/quarck/calnotify/calendar/EventAlertRecord.kt index 6612d389..81f7e5dd 100644 --- a/android/app/src/main/java/com/github/quarck/calnotify/calendar/EventAlertRecord.kt +++ b/android/app/src/main/java/com/github/quarck/calnotify/calendar/EventAlertRecord.kt @@ -99,6 +99,7 @@ object EventAlertFlags { const val IS_MUTED = 1L const val IS_TASK = 2L const val IS_ALARM = 4L + const val IS_PINNED = 8L } fun Long.isFlagSet(flag: Long) @@ -166,6 +167,10 @@ data class EventAlertRecord( get() = flags.isFlagSet(EventAlertFlags.IS_ALARM) set(value) { flags = flags.setFlag(EventAlertFlags.IS_ALARM, value) } + var isPinned: Boolean + get() = flags.isFlagSet(EventAlertFlags.IS_PINNED) + set(value) { flags = flags.setFlag(EventAlertFlags.IS_PINNED, value) } + val isUnmutedAlarm: Boolean get() = isAlarm && !isMuted diff --git a/android/app/src/main/java/com/github/quarck/calnotify/ui/ActiveEventsFragment.kt b/android/app/src/main/java/com/github/quarck/calnotify/ui/ActiveEventsFragment.kt index 2c7d2be4..eaa54808 100644 --- a/android/app/src/main/java/com/github/quarck/calnotify/ui/ActiveEventsFragment.kt +++ b/android/app/src/main/java/com/github/quarck/calnotify/ui/ActiveEventsFragment.kt @@ -350,6 +350,23 @@ class ActiveEventsFragment : Fragment(), EventListCallback, SearchableFragment, updateEmptyState() } + override fun onPinToggle(event: EventAlertRecord) { + val ctx = context ?: return + background { + getEventsStorage(ctx).use { db -> + val current = db.getEvent(event.eventId, event.instanceStartTime) + if (current != null) { + current.isPinned = !current.isPinned + db.updateEvent(current) + } + } + activity?.runOnUiThread { + loadEvents() + activity?.invalidateOptionsMenu() + } + } + } + override fun onScrollPositionChange(newPos: Int) { // Not needed for fragments - handled within adapter if needed } @@ -371,6 +388,8 @@ class ActiveEventsFragment : Fragment(), EventListCallback, SearchableFragment, override fun hasActiveEvents(): Boolean = adapter.hasActiveEvents + override fun hasUnpinnedActiveEvents(): Boolean = adapter.hasUnpinnedActiveEvents + override fun supportsSnoozeAll(): Boolean = true override fun supportsMuteAll(): Boolean = true @@ -381,6 +400,14 @@ class ActiveEventsFragment : Fragment(), EventListCallback, SearchableFragment, override fun anyForDismissAll(): Boolean = adapter.anyForDismissAllButRecentAndSnoozed + override fun supportsPinAll(): Boolean = true + + override fun anyForPinAll(): Boolean = adapter.anyForPinAll + + override fun anyForUnpinAll(): Boolean = adapter.anyForUnpinAll + + override fun getPinnedEventCount(): Int = adapter.pinnedCount + override fun onMuteAllComplete() { loadEvents() } @@ -389,6 +416,10 @@ class ActiveEventsFragment : Fragment(), EventListCallback, SearchableFragment, loadEvents() } + override fun onPinAllComplete() { + loadEvents() + } + override fun onFilterChanged() { loadEvents() } diff --git a/android/app/src/main/java/com/github/quarck/calnotify/ui/EventListAdapter.kt b/android/app/src/main/java/com/github/quarck/calnotify/ui/EventListAdapter.kt index 1677a24d..833bfe9b 100644 --- a/android/app/src/main/java/com/github/quarck/calnotify/ui/EventListAdapter.kt +++ b/android/app/src/main/java/com/github/quarck/calnotify/ui/EventListAdapter.kt @@ -50,6 +50,7 @@ interface EventListCallback { fun onItemRemoved(event: EventAlertRecord) fun onItemRestored(event: EventAlertRecord) // e.g. undo fun onScrollPositionChange(newPos: Int) + fun onPinToggle(event: EventAlertRecord) {} } interface SelectionModeCallback { @@ -91,6 +92,7 @@ class EventListAdapter( var undoButton: Button? + var pinImage: ImageView? var muteImage: ImageView? var taskImage: ImageView? val alarmImage: ImageView? @@ -114,6 +116,7 @@ class EventListAdapter( undoButton = itemView.find(R.id.card_view_button_undo) + pinImage = itemView.find(R.id.imageview_is_pinned_indicator) muteImage = itemView.find(R.id.imageview_is_muted_indicator) taskImage = itemView.find(R.id.imageview_is_task_indicator) alarmImage = itemView.find(R.id.imageview_is_alarm_indicator) @@ -370,6 +373,12 @@ class EventListAdapter( holder.eventTitleText.text = event.titleAsOneLine + holder.pinImage?.visibility = if (event.isPinned) View.VISIBLE else View.GONE + holder.pinImage?.setOnClickListener { + val ev = getEventAtPosition(holder.adapterPosition) + if (ev != null) callback.onPinToggle(ev) + } + holder.muteImage?.visibility = if (event.isMuted) View.VISIBLE else View.GONE holder.taskImage?.visibility = if (event.isTask) View.VISIBLE else View.GONE @@ -430,6 +439,9 @@ class EventListAdapter( val hasActiveEvents: Boolean get() = events.any { it.snoozedUntil == 0L } + val hasUnpinnedActiveEvents: Boolean + get() = events.any { it.snoozedUntil == 0L && !it.isPinned } + fun setSearchText(query: String?) { currentSearchString = query setEventsToDisplay() @@ -589,6 +601,15 @@ class EventListAdapter( val anyForMute: Boolean get() = events.any { it.snoozedUntil == 0L && it.isNotSpecial} + val anyForPinAll: Boolean + get() = events.any { it.snoozedUntil == 0L && it.isNotSpecial && !it.isPinned } + + val anyForUnpinAll: Boolean + get() = events.any { it.isPinned } + + val pinnedCount: Int + get() = events.count { it.isPinned } + // === Selection Mode Methods === fun enterSelectionMode(firstEvent: EventAlertRecord) { diff --git a/android/app/src/main/java/com/github/quarck/calnotify/ui/FilterState.kt b/android/app/src/main/java/com/github/quarck/calnotify/ui/FilterState.kt index 71980cd4..db767b83 100644 --- a/android/app/src/main/java/com/github/quarck/calnotify/ui/FilterState.kt +++ b/android/app/src/main/java/com/github/quarck/calnotify/ui/FilterState.kt @@ -184,6 +184,8 @@ data class FilterState( StatusOption.ACTIVE -> context.getString(R.string.filter_status_active) StatusOption.MUTED -> context.getString(R.string.filter_status_muted) StatusOption.RECURRING -> context.getString(R.string.filter_status_recurring) + StatusOption.PINNED -> context.getString(R.string.filter_status_pinned) + StatusOption.UNPINNED -> context.getString(R.string.filter_status_unpinned) } } parts.add(names.joinToString(", ")) @@ -275,7 +277,7 @@ data class FilterState( * Individual status filter options. Multiple can be selected (OR logic). */ enum class StatusOption { - SNOOZED, ACTIVE, MUTED, RECURRING; + SNOOZED, ACTIVE, MUTED, RECURRING, PINNED, UNPINNED; /** Check if an event matches this specific option */ fun matches(event: EventAlertRecord): Boolean = when (this) { @@ -283,6 +285,8 @@ enum class StatusOption { ACTIVE -> event.snoozedUntil == 0L MUTED -> event.isMuted RECURRING -> event.isRepeating + PINNED -> event.isPinned + UNPINNED -> !event.isPinned } } diff --git a/android/app/src/main/java/com/github/quarck/calnotify/ui/MainActivityModern.kt b/android/app/src/main/java/com/github/quarck/calnotify/ui/MainActivityModern.kt index adc4fcf2..9d44d0b2 100644 --- a/android/app/src/main/java/com/github/quarck/calnotify/ui/MainActivityModern.kt +++ b/android/app/src/main/java/com/github/quarck/calnotify/ui/MainActivityModern.kt @@ -228,13 +228,22 @@ class MainActivityModern : MainActivityBase() { dismissAllMenuItem?.isVisible = supportsDismissAll dismissAllMenuItem?.isEnabled = currentFragment?.anyForDismissAll() == true + // Show pin all / unpin all only for fragments that support it + val supportsPinAll = currentFragment?.supportsPinAll() == true + val pinAllMenuItem = menu.findItem(R.id.action_pin_all) + pinAllMenuItem?.isVisible = supportsPinAll + pinAllMenuItem?.isEnabled = currentFragment?.anyForPinAll() == true + val unpinAllMenuItem = menu.findItem(R.id.action_unpin_all) + unpinAllMenuItem?.isVisible = supportsPinAll + unpinAllMenuItem?.isEnabled = currentFragment?.anyForUnpinAll() == true + // Show snooze all only for fragments that support it (Active events) val snoozeAllMenuItem = menu.findItem(R.id.action_snooze_all) val supportsSnoozeAll = currentFragment?.supportsSnoozeAll() == true snoozeAllMenuItem?.isVisible = supportsSnoozeAll if (supportsSnoozeAll) { snoozeAllMenuItem?.title = resources.getString( - if (currentFragment?.hasActiveEvents() == true) R.string.snooze_all else R.string.change_all + if (currentFragment?.hasUnpinnedActiveEvents() == true) R.string.snooze_all else R.string.change_all ) } @@ -313,7 +322,7 @@ class MainActivityModern : MainActivityBase() { when (item.itemId) { R.id.action_snooze_all -> { val fragment = getCurrentSearchableFragment() - val isChange = fragment?.hasActiveEvents() != true + val isChange = fragment?.hasUnpinnedActiveEvents() != true val searchQuery = fragment?.getSearchQuery() val eventCount = fragment?.getDisplayedEventCount() ?: 0 val currentFilterState = getCurrentFilterState() @@ -332,6 +341,8 @@ class MainActivityModern : MainActivityBase() { return true } + val pinnedCount = fragment?.getPinnedEventCount() ?: 0 + startActivity( Intent(this, SnoozeAllActivity::class.java) .putExtra(Consts.INTENT_SNOOZE_ALL_IS_CHANGE, isChange) @@ -339,12 +350,17 @@ class MainActivityModern : MainActivityBase() { .putExtra(Consts.INTENT_SEARCH_QUERY, searchQuery) .putExtra(Consts.INTENT_SEARCH_QUERY_EVENT_COUNT, eventCount) .putExtra(Consts.INTENT_FILTER_STATE, currentFilterState.toBundle()) + .putExtra(Consts.INTENT_PINNED_EVENT_COUNT, pinnedCount) .setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) ) } R.id.action_mute_all -> onMuteAll() + R.id.action_pin_all -> onPinAll() + + R.id.action_unpin_all -> onUnpinAll() + R.id.action_dismiss_all -> onDismissAll() } @@ -390,6 +406,42 @@ class MainActivityModern : MainActivityBase() { getCurrentSearchableFragment()?.onMuteAllComplete() invalidateOptionsMenu() } + + private fun onPinAll() { + AlertDialog.Builder(this) + .setMessage(R.string.pin_all_events_question) + .setCancelable(false) + .setPositiveButton(android.R.string.yes) { _, _ -> + doPinAll() + } + .setNegativeButton(R.string.cancel) { _, _ -> } + .create() + .show() + } + + private fun doPinAll() { + ApplicationController.pinAllVisibleEvents(this) + getCurrentSearchableFragment()?.onPinAllComplete() + invalidateOptionsMenu() + } + + private fun onUnpinAll() { + AlertDialog.Builder(this) + .setMessage(R.string.unpin_all_events_question) + .setCancelable(false) + .setPositiveButton(android.R.string.yes) { _, _ -> + doUnpinAll() + } + .setNegativeButton(R.string.cancel) { _, _ -> } + .create() + .show() + } + + private fun doUnpinAll() { + ApplicationController.unpinAllVisibleEvents(this) + getCurrentSearchableFragment()?.onPinAllComplete() + invalidateOptionsMenu() + } // === Filter Chips === @@ -461,6 +513,8 @@ class MainActivityModern : MainActivityBase() { StatusOption.ACTIVE -> getString(R.string.filter_status_active) StatusOption.MUTED -> getString(R.string.filter_status_muted) StatusOption.RECURRING -> getString(R.string.filter_status_recurring) + StatusOption.PINNED -> getString(R.string.filter_status_pinned) + StatusOption.UNPINNED -> getString(R.string.filter_status_unpinned) } private fun showStatusFilterPopup(anchor: View) { diff --git a/android/app/src/main/java/com/github/quarck/calnotify/ui/SearchableFragment.kt b/android/app/src/main/java/com/github/quarck/calnotify/ui/SearchableFragment.kt index fe0c1cca..4d1bcc6d 100644 --- a/android/app/src/main/java/com/github/quarck/calnotify/ui/SearchableFragment.kt +++ b/android/app/src/main/java/com/github/quarck/calnotify/ui/SearchableFragment.kt @@ -41,6 +41,9 @@ interface SearchableFragment { /** Whether there are active (non-snoozed) events - for snooze all label */ fun hasActiveEvents(): Boolean = true + /** Whether there are active non-pinned events (for snooze-vs-change-all decision) */ + fun hasUnpinnedActiveEvents(): Boolean = hasActiveEvents() + /** Whether this fragment supports snooze all action */ fun supportsSnoozeAll(): Boolean = false @@ -56,12 +59,27 @@ interface SearchableFragment { /** Whether there are any events eligible for dismiss all */ fun anyForDismissAll(): Boolean = false + /** Whether this fragment supports pin all action */ + fun supportsPinAll(): Boolean = false + + /** Whether there are any events eligible for pin all */ + fun anyForPinAll(): Boolean = false + + /** Whether there are any events eligible for unpin all */ + fun anyForUnpinAll(): Boolean = false + + /** Get count of pinned events in current displayed set (for snooze all exclusion message) */ + fun getPinnedEventCount(): Int = 0 + /** Called when mute all action is triggered - fragment should reload data */ fun onMuteAllComplete() {} /** Called when dismiss all action is triggered - fragment should reload data */ fun onDismissAllComplete() {} + /** Called when pin/unpin all action is triggered - fragment should reload data */ + fun onPinAllComplete() {} + /** Called when filter state changes - fragment should reload data with new filter */ fun onFilterChanged() {} } diff --git a/android/app/src/main/java/com/github/quarck/calnotify/ui/SnoozeAllActivity.kt b/android/app/src/main/java/com/github/quarck/calnotify/ui/SnoozeAllActivity.kt index 89e596f6..ae8bb87b 100644 --- a/android/app/src/main/java/com/github/quarck/calnotify/ui/SnoozeAllActivity.kt +++ b/android/app/src/main/java/com/github/quarck/calnotify/ui/SnoozeAllActivity.kt @@ -104,6 +104,7 @@ open class SnoozeAllActivity : AppCompatActivity() { private var searchQuery: String? = null private var filterState: FilterState? = null private var selectedEventKeys: Set? = null // For multi-select mode + private var pinnedEventCount: Int = 0 val clock: CNPlusClockInterface = CNPlusSystemClock() @@ -127,6 +128,7 @@ open class SnoozeAllActivity : AppCompatActivity() { searchQuery = intent.getStringExtra(Consts.INTENT_SEARCH_QUERY) filterState = FilterState.fromBundle(intent.getBundleExtra(Consts.INTENT_FILTER_STATE)) + pinnedEventCount = intent.getIntExtra(Consts.INTENT_PINNED_EVENT_COUNT, 0) // Check for multi-select mode val selectedKeysArray = intent.getStringArrayExtra(ActiveEventsFragment.INTENT_SELECTED_EVENT_KEYS) @@ -372,7 +374,7 @@ open class SnoozeAllActivity : AppCompatActivity() { val hasSearch = !searchQuery.isNullOrEmpty() val hasFilter = filterState?.hasActiveFilters() == true && filterDescription != null - return when { + val baseMessage = when { hasSearch || hasFilter -> { val combined = when { hasSearch && hasFilter -> getString(R.string.filter_description_search_and_filters, searchQuery, filterDescription) @@ -387,6 +389,15 @@ open class SnoozeAllActivity : AppCompatActivity() { snoozeAllIsChange -> getString(R.string.change_all_notification) else -> getString(R.string.snooze_all_confirmation) } + + if (pinnedEventCount > 0) { + val pinnedSuffix = resources.getQuantityString( + R.plurals.pinned_events_excluded, pinnedEventCount, pinnedEventCount + ) + return "$baseMessage\n$pinnedSuffix" + } + + return baseMessage } @Suppress("unused", "UNUSED_PARAMETER") diff --git a/android/app/src/main/java/com/github/quarck/calnotify/ui/ViewEventActivityNoRecents.kt b/android/app/src/main/java/com/github/quarck/calnotify/ui/ViewEventActivityNoRecents.kt index e87fc94f..d7a2e784 100644 --- a/android/app/src/main/java/com/github/quarck/calnotify/ui/ViewEventActivityNoRecents.kt +++ b/android/app/src/main/java/com/github/quarck/calnotify/ui/ViewEventActivityNoRecents.kt @@ -414,6 +414,9 @@ open class ViewEventActivityNoRecents : AppCompatActivity() { menuItemUnMute.isVisible = event.isMuted } + popup.menu.findItem(R.id.action_pin_event)?.isVisible = !event.isPinned + popup.menu.findItem(R.id.action_unpin_event)?.isVisible = event.isPinned + // Show "Back to upcoming" for snoozed events whose alert time hasn't passed val menuItemUnsnooze = popup.menu.findItem(R.id.action_unsnooze_to_upcoming) if (menuItemUnsnooze != null) { @@ -492,6 +495,16 @@ open class ViewEventActivityNoRecents : AppCompatActivity() { true } + R.id.action_pin_event -> { + togglePinForEvent(true) + true + } + + R.id.action_unpin_event -> { + togglePinForEvent(false) + true + } + R.id.action_open_in_calendar -> { openEventInCalendar(event) finish() @@ -513,6 +526,17 @@ open class ViewEventActivityNoRecents : AppCompatActivity() { popup.show() } + private fun togglePinForEvent(pinned: Boolean) { + getEventsStorage(this).use { db -> + val current = db.getEvent(event.eventId, event.instanceStartTime) + if (current != null) { + current.isPinned = pinned + db.updateEvent(current) + } + } + event.isPinned = pinned + } + private fun formatPreset(preset: Long): String { val num: Long val unit: String diff --git a/android/app/src/main/res/drawable/ic_push_pin_black_24dp.xml b/android/app/src/main/res/drawable/ic_push_pin_black_24dp.xml new file mode 100644 index 00000000..6affe234 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_push_pin_black_24dp.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/layout/event_card_compact.xml b/android/app/src/main/res/layout/event_card_compact.xml index 918bb64e..f6e2c674 100644 --- a/android/app/src/main/res/layout/event_card_compact.xml +++ b/android/app/src/main/res/layout/event_card_compact.xml @@ -138,6 +138,21 @@ android:layout_marginEnd="4dp" > + + + + + + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index c44cc324..623bb2b3 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -397,6 +397,20 @@ Mute all visible Mute all visible events? (Tasks excluded) + Notification is pinned + Pin + Unpin + Pin all visible + Unpin all + Pin all visible events?\nPinned events are excluded from batch snooze. + Unpin all pinned events? + Pinned + Unpinned + + (%1$d pinned event excluded) + (%1$d pinned events excluded) + + Mute when it fires Unmute diff --git a/android/app/src/test/java/com/github/quarck/calnotify/app/ApplicationControllerCoreRobolectricTest.kt b/android/app/src/test/java/com/github/quarck/calnotify/app/ApplicationControllerCoreRobolectricTest.kt index e4b30963..2e9ff30f 100644 --- a/android/app/src/test/java/com/github/quarck/calnotify/app/ApplicationControllerCoreRobolectricTest.kt +++ b/android/app/src/test/java/com/github/quarck/calnotify/app/ApplicationControllerCoreRobolectricTest.kt @@ -365,6 +365,103 @@ class ApplicationControllerCoreRobolectricTest { assertFalse("No events should be muted", events.any { it.isMuted }) } + @Test + fun testMuteAllVisibleEvents_mutesPinnedEvents() { + val pinnedEvent = createTestEvent(eventId = 1, isMuted = false).apply { isPinned = true } + mockEventsStorage.addEvent(pinnedEvent) + mockEventsStorage.addEvent(createTestEvent(eventId = 2, isMuted = false)) + + ApplicationController.muteAllVisibleEvents(context) + + val events = mockEventsStorage.events + val pinned = events.find { it.eventId == 1L } + val normal = events.find { it.eventId == 2L } + + assertTrue("Pinned event should be muted (pinning only affects batch snooze)", pinned?.isMuted == true) + assertTrue("Normal event should be muted", normal?.isMuted == true) + } + + // === pinAllVisibleEvents / unpinAllVisibleEvents tests === + + @Test + fun testPinAllVisibleEvents_pinsVisibleEvents() { + mockEventsStorage.addEvent(createTestEvent(eventId = 1)) + mockEventsStorage.addEvent(createTestEvent(eventId = 2)) + + ApplicationController.pinAllVisibleEvents(context) + + val events = mockEventsStorage.events + assertTrue("All events should be pinned", events.all { it.isPinned }) + } + + @Test + fun testPinAllVisibleEvents_skipsSnoozedEvents() { + mockEventsStorage.addEvent(createTestEvent(eventId = 1, snoozedUntil = baseTime + 3600000L)) + mockEventsStorage.addEvent(createTestEvent(eventId = 2)) + + ApplicationController.pinAllVisibleEvents(context) + + val events = mockEventsStorage.events + val snoozed = events.find { it.eventId == 1L } + val visible = events.find { it.eventId == 2L } + + assertFalse("Snoozed event should NOT be pinned", snoozed?.isPinned == true) + assertTrue("Visible event should be pinned", visible?.isPinned == true) + } + + @Test + fun testPinAllVisibleEvents_pinsTaskEvents() { + mockEventsStorage.addEvent(createTestEvent(eventId = 1, isTask = true)) + mockEventsStorage.addEvent(createTestEvent(eventId = 2)) + + ApplicationController.pinAllVisibleEvents(context) + + val events = mockEventsStorage.events + val task = events.find { it.eventId == 1L } + val normal = events.find { it.eventId == 2L } + + assertTrue("Task event should be pinned (pinning is orthogonal to task status)", task?.isPinned == true) + assertTrue("Normal event should be pinned", normal?.isPinned == true) + } + + @Test + fun testPinAllVisibleEvents_skipsAlreadyPinned() { + val alreadyPinned = createTestEvent(eventId = 1).apply { isPinned = true } + mockEventsStorage.addEvent(alreadyPinned) + mockEventsStorage.addEvent(createTestEvent(eventId = 2)) + + ApplicationController.pinAllVisibleEvents(context) + + val events = mockEventsStorage.events + assertTrue("All events should be pinned", events.all { it.isPinned }) + } + + @Test + fun testUnpinAllVisibleEvents_unpinsAllPinnedEvents() { + val pinned1 = createTestEvent(eventId = 1).apply { isPinned = true } + val pinned2 = createTestEvent(eventId = 2).apply { isPinned = true } + mockEventsStorage.addEvent(pinned1) + mockEventsStorage.addEvent(pinned2) + + ApplicationController.unpinAllVisibleEvents(context) + + val events = mockEventsStorage.events + assertFalse("No events should be pinned", events.any { it.isPinned }) + } + + @Test + fun testUnpinAllVisibleEvents_leavesUnpinnedAlone() { + val pinned = createTestEvent(eventId = 1).apply { isPinned = true } + val unpinned = createTestEvent(eventId = 2) + mockEventsStorage.addEvent(pinned) + mockEventsStorage.addEvent(unpinned) + + ApplicationController.unpinAllVisibleEvents(context) + + val events = mockEventsStorage.events + assertFalse("No events should be pinned after unpin all", events.any { it.isPinned }) + } + // === onEventAlarm tests === @Test @@ -1183,5 +1280,85 @@ class ApplicationControllerCoreRobolectricTest { assertEquals("Should not shorten existing snooze", baseTime + 7200000L, updatedEvent.snoozedUntil) } + + // === Pinned event exclusion from snoozeAllEvents === + + @Test + fun testSnoozeAllEvents_skipsPinnedEvents() { + val pinnedEvent = createTestEvent(eventId = 1).apply { isPinned = true } + mockEventsStorage.addEvent(pinnedEvent) + mockEventsStorage.addEvent(createTestEvent(eventId = 2)) + + ApplicationController.snoozeAllEvents(context, 3600000L, false, false, null, null) + + val pinned = mockEventsStorage.events.find { it.eventId == 1L } + val normal = mockEventsStorage.events.find { it.eventId == 2L } + + assertEquals("Pinned event should NOT be snoozed", 0L, pinned!!.snoozedUntil) + assertTrue("Normal event should be snoozed", normal!!.snoozedUntil > 0) + } + + @Test + fun testSnoozeAllEvents_allPinned_returnsNull() { + val pinned1 = createTestEvent(eventId = 1).apply { isPinned = true } + val pinned2 = createTestEvent(eventId = 2).apply { isPinned = true } + mockEventsStorage.addEvent(pinned1) + mockEventsStorage.addEvent(pinned2) + + val result = ApplicationController.snoozeAllEvents(context, 3600000L, false, false, null, null) + + assertNull("Result should be null when all events are pinned", result) + } + + @Test + fun testSnoozeAllEvents_pinnedExcludedEvenWhenMatchingSearch() { + val pinnedEvent = createTestEvent(eventId = 1).apply { isPinned = true } + pinnedEvent.title = "Important Meeting" + mockEventsStorage.addEvent(pinnedEvent) + mockEventsStorage.addEvent(createTestEvent(eventId = 2).also { it.title = "Important Lunch" }) + + ApplicationController.snoozeAllEvents( + context, 3600000L, false, false, searchQuery = "Important", filterState = null + ) + + val pinned = mockEventsStorage.events.find { it.eventId == 1L } + val normal = mockEventsStorage.events.find { it.eventId == 2L } + + assertEquals("Pinned event should NOT be snoozed even though it matches search", + 0L, pinned!!.snoozedUntil) + assertTrue("Unpinned matching event should be snoozed", normal!!.snoozedUntil > 0) + } + + @Test + fun testSnoozeAllEvents_pinnedExcludedEvenWhenMatchingFilter() { + val pinnedEvent = createTestEvent(eventId = 1).apply { isPinned = true } + mockEventsStorage.addEvent(pinnedEvent) + mockEventsStorage.addEvent(createTestEvent(eventId = 2)) + + val filterState = FilterState(statusFilters = setOf(StatusOption.ACTIVE)) + + ApplicationController.snoozeAllEvents( + context, 3600000L, false, false, null, filterState + ) + + val pinned = mockEventsStorage.events.find { it.eventId == 1L } + val normal = mockEventsStorage.events.find { it.eventId == 2L } + + assertEquals("Pinned event should NOT be snoozed even when matching filter", + 0L, pinned!!.snoozedUntil) + assertTrue("Unpinned event should be snoozed", normal!!.snoozedUntil > 0) + } + + @Test + fun testSnoozeSelectedEvents_includesPinnedEvents() { + val pinnedEvent = createTestEvent(eventId = 1, instanceStartTime = baseTime).apply { isPinned = true } + mockEventsStorage.addEvent(pinnedEvent) + + val selectedKeys = setOf("${pinnedEvent.eventId}:${pinnedEvent.instanceStartTime}") + ApplicationController.snoozeSelectedEvents(context, selectedKeys, 3600000L, false) + + val updated = mockEventsStorage.events.first() + assertTrue("Explicitly selected pinned event SHOULD be snoozed", updated.snoozedUntil > 0) + } } diff --git a/android/app/src/test/java/com/github/quarck/calnotify/app/SnoozeRobolectricTest.kt b/android/app/src/test/java/com/github/quarck/calnotify/app/SnoozeRobolectricTest.kt index 2b1b0438..f5260e3d 100644 --- a/android/app/src/test/java/com/github/quarck/calnotify/app/SnoozeRobolectricTest.kt +++ b/android/app/src/test/java/com/github/quarck/calnotify/app/SnoozeRobolectricTest.kt @@ -294,6 +294,58 @@ class SnoozeRobolectricTest { assertEquals("Normal event should NOT be snoozed", 0L, updatedNormal!!.snoozedUntil) } + // === Pinned exclusion from snoozeAll === + + @Test + fun testSnoozeAllEvents_skipsPinnedEvents() { + val pinnedEvent = createTestEvent(eventId = 600L, title = "Pinned").apply { isPinned = true } + val normalEvent = createTestEvent(eventId = 601L, title = "Normal") + mockEventsStorage.addEvent(pinnedEvent) + mockEventsStorage.addEvent(normalEvent) + + val snoozeDelay = 30 * 60 * 1000L + + val result = ApplicationController.snoozeAllEvents( + context, snoozeDelay, isChange = false, onlySnoozeVisible = false + ) + + assertNotNull("Snooze result should not be null", result) + + val updatedPinned = mockEventsStorage.getEvent(pinnedEvent.eventId, pinnedEvent.instanceStartTime) + val updatedNormal = mockEventsStorage.getEvent(normalEvent.eventId, normalEvent.instanceStartTime) + + assertEquals("Pinned event should NOT be snoozed", 0L, updatedPinned!!.snoozedUntil) + assertTrue("Normal event should be snoozed", updatedNormal!!.snoozedUntil > 0) + } + + @Test + fun testSnoozeAllCollapsedEvents_skipsPinnedEvents() { + val pinnedCollapsed = createTestEvent( + eventId = 700L, + displayStatus = EventDisplayStatus.DisplayedCollapsed + ).apply { isPinned = true } + val normalCollapsed = createTestEvent( + eventId = 701L, + displayStatus = EventDisplayStatus.DisplayedCollapsed + ) + mockEventsStorage.addEvent(pinnedCollapsed) + mockEventsStorage.addEvent(normalCollapsed) + + val snoozeDelay = 30 * 60 * 1000L + + val result = ApplicationController.snoozeAllCollapsedEvents( + context, snoozeDelay, isChange = false, onlySnoozeVisible = false + ) + + assertNotNull("Snooze result should not be null", result) + + val updatedPinned = mockEventsStorage.getEvent(pinnedCollapsed.eventId, pinnedCollapsed.instanceStartTime) + val updatedNormal = mockEventsStorage.getEvent(normalCollapsed.eventId, normalCollapsed.instanceStartTime) + + assertEquals("Pinned collapsed event should NOT be snoozed", 0L, updatedPinned!!.snoozedUntil) + assertTrue("Normal collapsed event should be snoozed", updatedNormal!!.snoozedUntil > 0) + } + // === onlySnoozeVisible tests === @Test diff --git a/android/app/src/test/java/com/github/quarck/calnotify/ui/EventListAdapterSelectionTest.kt b/android/app/src/test/java/com/github/quarck/calnotify/ui/EventListAdapterSelectionTest.kt index 481b8e1e..474a6535 100644 --- a/android/app/src/test/java/com/github/quarck/calnotify/ui/EventListAdapterSelectionTest.kt +++ b/android/app/src/test/java/com/github/quarck/calnotify/ui/EventListAdapterSelectionTest.kt @@ -80,7 +80,8 @@ class EventListAdapterSelectionTest { private fun createTestEvent( eventId: Long = 1L, instanceStartTime: Long = System.currentTimeMillis(), - title: String = "Test Event" + title: String = "Test Event", + snoozedUntil: Long = 0L ): EventAlertRecord { return EventAlertRecord( calendarId = 1L, @@ -97,6 +98,7 @@ class EventListAdapterSelectionTest { instanceEndTime = instanceStartTime + 60 * 60 * 1000, location = "", lastStatusChangeTime = 0L, + snoozedUntil = snoozedUntil, displayStatus = com.github.quarck.calnotify.calendar.EventDisplayStatus.Hidden, color = 0, eventStatus = com.github.quarck.calnotify.calendar.EventStatus.Confirmed, @@ -349,6 +351,48 @@ class EventListAdapterSelectionTest { assertEquals(1, adapter.getSelectedEvents().size) } + // === hasUnpinnedActiveEvents Tests === + + @Test + fun hasUnpinnedActiveEvents_true_when_unpinned_active_exists() { + val event = createTestEvent(eventId = 1L) + setAdapterEvents(event) + + assertTrue(adapter.hasUnpinnedActiveEvents) + } + + @Test + fun hasUnpinnedActiveEvents_false_when_only_pinned_active() { + val pinned = createTestEvent(eventId = 1L).apply { isPinned = true } + setAdapterEvents(pinned) + + assertFalse(adapter.hasUnpinnedActiveEvents) + } + + @Test + fun hasUnpinnedActiveEvents_false_when_no_events() { + assertFalse(adapter.hasUnpinnedActiveEvents) + } + + @Test + fun hasUnpinnedActiveEvents_true_with_mix_of_pinned_and_unpinned_active() { + val pinned = createTestEvent(eventId = 1L).apply { isPinned = true } + val unpinned = createTestEvent(eventId = 2L) + setAdapterEvents(pinned, unpinned) + + assertTrue(adapter.hasUnpinnedActiveEvents) + } + + @Test + fun hasUnpinnedActiveEvents_false_when_only_active_is_pinned_and_rest_snoozed() { + val pinnedActive = createTestEvent(eventId = 1L).apply { isPinned = true } + val snoozedUnpinned = createTestEvent(eventId = 2L, snoozedUntil = 1000L) + val snoozedUnpinned2 = createTestEvent(eventId = 3L, snoozedUntil = 2000L) + setAdapterEvents(pinnedActive, snoozedUnpinned, snoozedUnpinned2) + + assertFalse(adapter.hasUnpinnedActiveEvents) + } + // === Mock Callback === private class MockEventListCallback : EventListCallback { diff --git a/android/app/src/test/java/com/github/quarck/calnotify/ui/FilterStateTest.kt b/android/app/src/test/java/com/github/quarck/calnotify/ui/FilterStateTest.kt index a2ee3d95..82e7073a 100644 --- a/android/app/src/test/java/com/github/quarck/calnotify/ui/FilterStateTest.kt +++ b/android/app/src/test/java/com/github/quarck/calnotify/ui/FilterStateTest.kt @@ -71,6 +71,7 @@ class FilterStateTest { private fun createSnoozedEvent() = createEvent(snoozedUntil = TestTimeConstants.STANDARD_TEST_TIME + 3600000) private fun createMutedEvent() = createEvent(isMuted = true) private fun createRecurringEvent() = createEvent(isRepeating = true) + private fun createPinnedEvent() = createEvent().apply { isPinned = true } // === FilterState.matchesStatus() Tests === @@ -82,6 +83,7 @@ class FilterStateTest { assertTrue(filter.matchesStatus(createSnoozedEvent())) assertTrue(filter.matchesStatus(createMutedEvent())) assertTrue(filter.matchesStatus(createRecurringEvent())) + assertTrue(filter.matchesStatus(createPinnedEvent())) } @Test @@ -125,6 +127,27 @@ class FilterStateTest { assertTrue(filter.matchesStatus(createRecurringEvent())) } + @Test + fun `single PINNED filter matches only pinned events`() { + val filter = FilterState(statusFilters = setOf(StatusOption.PINNED)) + + assertFalse(filter.matchesStatus(createActiveEvent())) + assertFalse(filter.matchesStatus(createSnoozedEvent())) + assertFalse(filter.matchesStatus(createMutedEvent())) + assertFalse(filter.matchesStatus(createRecurringEvent())) + assertTrue(filter.matchesStatus(createPinnedEvent())) + } + + @Test + fun `multi-select PINNED or SNOOZED matches both`() { + val filter = FilterState(statusFilters = setOf(StatusOption.PINNED, StatusOption.SNOOZED)) + + assertFalse(filter.matchesStatus(createActiveEvent())) + assertTrue(filter.matchesStatus(createSnoozedEvent())) + assertTrue(filter.matchesStatus(createPinnedEvent())) + assertFalse(filter.matchesStatus(createMutedEvent())) + } + @Test fun `multi-select uses OR logic - SNOOZED or MUTED`() { val filter = FilterState(statusFilters = setOf(StatusOption.SNOOZED, StatusOption.MUTED)) @@ -193,6 +216,15 @@ class FilterStateTest { assertFalse(StatusOption.RECURRING.matches(notRecurringEvent)) } + @Test + fun `StatusOption PINNED matches event with isPinned true`() { + val pinnedEvent = createEvent().apply { isPinned = true } + val notPinnedEvent = createEvent() + + assertTrue(StatusOption.PINNED.matches(pinnedEvent)) + assertFalse(StatusOption.PINNED.matches(notPinnedEvent)) + } + // === Edge Cases === @Test @@ -209,6 +241,16 @@ class FilterStateTest { assertFalse(StatusOption.RECURRING.matches(snoozedMutedEvent)) } + @Test + fun `pinned and muted event matches both PINNED and MUTED`() { + val event = createEvent(isMuted = true).apply { isPinned = true } + + assertTrue(StatusOption.PINNED.matches(event)) + assertTrue(StatusOption.MUTED.matches(event)) + assertTrue(StatusOption.ACTIVE.matches(event)) // snoozedUntil=0 + assertFalse(StatusOption.SNOOZED.matches(event)) + } + @Test fun `recurring muted active event matches multiple filters`() { val event = createEvent( @@ -543,6 +585,13 @@ class FilterStateTest { } } + @Test + fun `toBundle and fromBundle round-trip PINNED status filter`() { + val original = FilterState(statusFilters = setOf(StatusOption.PINNED, StatusOption.MUTED)) + val restored = FilterState.fromBundle(original.toBundle()) + assertEquals(setOf(StatusOption.PINNED, StatusOption.MUTED), restored.statusFilters) + } + // === hasActiveFilters() Tests === @Test diff --git a/android/app/src/test/java/com/github/quarck/calnotify/ui/SnoozeAllActivityRobolectricTest.kt b/android/app/src/test/java/com/github/quarck/calnotify/ui/SnoozeAllActivityRobolectricTest.kt index 3f84c3f1..e8fa0e49 100644 --- a/android/app/src/test/java/com/github/quarck/calnotify/ui/SnoozeAllActivityRobolectricTest.kt +++ b/android/app/src/test/java/com/github/quarck/calnotify/ui/SnoozeAllActivityRobolectricTest.kt @@ -181,6 +181,69 @@ class SnoozeAllActivityRobolectricTest { scenario.close() } + // === Pinned Event Count Tests === + + @Test + fun snoozeAllActivity_reads_pinned_event_count_from_intent() { + fixture.seedEvents(3) + + val intent = Intent(fixture.context, SnoozeAllActivity::class.java).apply { + putExtra(Consts.INTENT_SNOOZE_ALL_KEY, true) + putExtra(Consts.INTENT_SEARCH_QUERY_EVENT_COUNT, 5) + putExtra(Consts.INTENT_PINNED_EVENT_COUNT, 2) + } + + val scenario = fixture.launchSnoozeAllActivityWithIntent(intent) + + scenario.onActivity { activity -> + // Verify the activity stored the pinned count + // We test the confirmation message indirectly: click a preset to trigger the dialog + val preset1 = activity.findViewById(R.id.snooze_view_snooze_present1) + assertNotNull(preset1) + preset1.performClick() + + // The AlertDialog should be shown - check via Robolectric shadow + val dialog = org.robolectric.shadows.ShadowAlertDialog.getLatestAlertDialog() + assertNotNull("Confirmation dialog should be shown", dialog) + + val shadow = org.robolectric.Shadows.shadowOf(dialog) + val message = shadow.message?.toString() ?: "" + assertTrue("Confirmation should mention pinned exclusion", + message.contains("pinned") && message.contains("2")) + } + + scenario.close() + } + + @Test + fun snoozeAllActivity_no_pinned_suffix_when_count_is_zero() { + fixture.seedEvents(3) + + val intent = Intent(fixture.context, SnoozeAllActivity::class.java).apply { + putExtra(Consts.INTENT_SNOOZE_ALL_KEY, true) + putExtra(Consts.INTENT_SEARCH_QUERY_EVENT_COUNT, 5) + putExtra(Consts.INTENT_PINNED_EVENT_COUNT, 0) + } + + val scenario = fixture.launchSnoozeAllActivityWithIntent(intent) + + scenario.onActivity { activity -> + val preset1 = activity.findViewById(R.id.snooze_view_snooze_present1) + assertNotNull(preset1) + preset1.performClick() + + val dialog = org.robolectric.shadows.ShadowAlertDialog.getLatestAlertDialog() + assertNotNull("Confirmation dialog should be shown", dialog) + + val shadow = org.robolectric.Shadows.shadowOf(dialog) + val message = shadow.message?.toString() ?: "" + assertFalse("Confirmation should NOT mention pinned when count is 0", + message.contains("pinned")) + } + + scenario.close() + } + // === Preset Buttons Have Text Tests === @Test diff --git a/android/app/src/test/java/com/github/quarck/calnotify/ui/ViewEventActivityRobolectricTest.kt b/android/app/src/test/java/com/github/quarck/calnotify/ui/ViewEventActivityRobolectricTest.kt index fb0fb462..6a930ffb 100644 --- a/android/app/src/test/java/com/github/quarck/calnotify/ui/ViewEventActivityRobolectricTest.kt +++ b/android/app/src/test/java/com/github/quarck/calnotify/ui/ViewEventActivityRobolectricTest.kt @@ -17,6 +17,7 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowPopupMenu import org.robolectric.shadows.ShadowToast /** @@ -379,5 +380,77 @@ class ViewEventActivityRobolectricTest { scenario.close() } + + // === Pin/Unpin Popup Menu Tests === + + @Test + fun popupMenu_shows_pin_for_unpinned_event() { + val event = fixture.createEvent(title = "Unpinned Event") + + val scenario = fixture.launchViewEventActivity(event) + + scenario.onActivity { activity -> + val menuButton = activity.findViewById(R.id.snooze_view_menu) + assertNotNull("Menu button should exist", menuButton) + menuButton.performClick() + + val shadowPopup = ShadowPopupMenu.getLatestPopupMenu() + assertNotNull("Popup menu should be shown", shadowPopup) + + val pinItem = shadowPopup.menu.findItem(R.id.action_pin_event) + val unpinItem = shadowPopup.menu.findItem(R.id.action_unpin_event) + + assertTrue("Pin should be visible for unpinned event", pinItem.isVisible) + assertFalse("Unpin should be hidden for unpinned event", unpinItem.isVisible) + } + + scenario.close() + } + + @Test + fun popupMenu_shows_unpin_for_pinned_event() { + val event = fixture.createEvent(title = "Pinned Event").apply { isPinned = true } + fixture.eventsStorage.updateEvent(event) + + val scenario = fixture.launchViewEventActivity(event) + + scenario.onActivity { activity -> + val menuButton = activity.findViewById(R.id.snooze_view_menu) + assertNotNull("Menu button should exist", menuButton) + menuButton.performClick() + + val shadowPopup = ShadowPopupMenu.getLatestPopupMenu() + assertNotNull("Popup menu should be shown", shadowPopup) + + val pinItem = shadowPopup.menu.findItem(R.id.action_pin_event) + val unpinItem = shadowPopup.menu.findItem(R.id.action_unpin_event) + + assertFalse("Pin should be hidden for pinned event", pinItem.isVisible) + assertTrue("Unpin should be visible for pinned event", unpinItem.isVisible) + } + + scenario.close() + } + + @Test + fun popupMenu_shows_pin_for_task_event() { + val event = fixture.createEvent(title = "Task Event", isTask = true) + + val scenario = fixture.launchViewEventActivity(event) + + scenario.onActivity { activity -> + val menuButton = activity.findViewById(R.id.snooze_view_menu) + assertNotNull("Menu button should exist", menuButton) + menuButton.performClick() + + val shadowPopup = ShadowPopupMenu.getLatestPopupMenu() + assertNotNull("Popup menu should be shown", shadowPopup) + + val pinItem = shadowPopup.menu.findItem(R.id.action_pin_event) + assertTrue("Pin should be visible for task event (pinning is orthogonal)", pinItem.isVisible) + } + + scenario.close() + } } diff --git a/docs/dev_todo/event_pinning.md b/docs/dev_todo/event_pinning.md new file mode 100644 index 00000000..392f0bf6 --- /dev/null +++ b/docs/dev_todo/event_pinning.md @@ -0,0 +1,329 @@ +# Feature: Pinned Notifications Excluded from Batch Snooze + +**GitHub Issue:** [#186](https://github.com/williscool/CalendarNotification/issues/186) + +## Background + +When managing many reminders, users often use "Snooze All" or "Change All" to quickly push everything back. However, certain critical events (flight check-in, medication, imminent meeting) should not be accidentally snoozed this way — they need to stay visible and active. + +The app already has a bitmask `flags` field on `EventAlertRecord` (with `IS_MUTED`, `IS_TASK`, `IS_ALARM`), an `isNotSpecial` exclusion in `snoozeEvents()`, and a mature filter pill system (`FilterState`, `StatusOption`). Pinning builds naturally on all of these. + +### Batch snooze paths today + +| Entry Point | Method Called | Filter Applied | +|-------------|-------------|---------------| +| MainActivityModern menu → SnoozeAllActivity | `snoozeAllEvents(…, searchQuery, filterState)` | search + FilterState | +| Notification group swipe/action | `snoozeAllEvents(ctx, delay, false, true)` | none (onlySnoozeVisible) | +| Notification collapsed group swipe | `snoozeAllCollapsedEvents(ctx, delay, false, true)` | displayStatus == Collapsed | +| Multi-select → SnoozeAllActivity | `snoozeSelectedEvents(ctx, keys, delay, isChange)` | explicit key set | + +## Goal + +Add the ability to "pin" event notifications so they are excluded from batch snooze operations (Snooze All, Change All, Snooze All Collapsed). Pinned events can still be individually snoozed/dismissed. A visual indicator and filter pill make pinned state visible and filterable in the event list. + +## Non-Goals + +- **Pin indicator icons in ViewEventActivity** — No visual pin icons/badges on the detail view; the "Pin"/"Unpin" menu option in the popup is in scope. +- **Pin-related filter pill on Dismissed tab** — Dismissed tab has no Status chip today; stays that way. +- **Notification action button for pin/unpin** — Adding a "Pin" action on individual notifications is future work. +- **Dismiss All exclusion** — Dismiss All doesn't support any filtering today ([#215 context](./snooze_all_filter_pills.md)). Pinning can be added there when Dismiss All gets filter support. +- **`#pin` hashtag integration** — [#185](https://github.com/williscool/CalendarNotification/issues/185) extended hashtag system is a separate feature. +- **Pin state sync to cloud** — Pin state lives in the existing `flags` column which already syncs via cr-sqlite if enabled. + +## Key Decisions Summary + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Storage mechanism | **`IS_PINNED` flag on existing `flags` bitmask** | No DB migration, consistent with `isMuted`/`isTask`/`isAlarm` pattern | +| Batch exclusion scope | **`snoozeAllEvents` + `snoozeAllCollapsedEvents` + `muteAllVisibleEvents`** | Covers UI, notification, and mute-all paths; `snoozeSelectedEvents` NOT excluded (explicit selection overrides pin) | +| Pin/unpin UI | **Icon toggle on event card + ViewEventActivity popup menu + overflow menu "Pin All"** | Icon for individual toggle; popup menu Pin/Unpin (same as Mute/Un-Mute); overflow menu for batch (same as "Mute all visible") | +| Filter integration | **Add `PINNED` to `StatusOption` on Active + Upcoming tabs** | Uses existing filter pill infrastructure; OR logic with other status options | +| Event count in Snooze All | **Show "excluding N pinned" in confirmation** | Transparent UX — user sees exactly what's happening | + +## Current Architecture + +### EventAlertFlags (bitmask on `flags: Long`) + +``` +Bit 0 (1L): IS_MUTED +Bit 1 (2L): IS_TASK +Bit 2 (4L): IS_ALARM +Bit 3 (8L): IS_PINNED ← new +``` + +The `flags` field maps to Room column `i1` in `EventAlertEntity`. No migration needed — adding a new bit to an existing `Long` column is backward-compatible (existing rows have bit 3 = 0 → `isPinned = false`). + +### Batch snooze flow + +``` +snoozeAllEvents() / snoozeAllCollapsedEvents() + ↓ builds filter lambda +snoozeEvents(context, filter, ...) + ↓ loads events +db.events.filter { it.isNotSpecial && filter(it) } + ↓ for each matching event +db.updateEvent(event, snoozedUntil = ..., displayStatus = Hidden) +``` + +Pinned exclusion goes in the filter lambda of `snoozeAllEvents` and `snoozeAllCollapsedEvents`, NOT in the base `snoozeEvents`, so `snoozeSelectedEvents` (explicit multi-select) is unaffected. + +## Design Decisions + +### Why filter lambda, not `snoozeEvents` base? + +`snoozeEvents()` is the shared workhorse for all batch operations including `snoozeSelectedEvents()`. If we add `!isPinned` in the base, explicitly selected pinned events would be silently skipped — bad UX. By adding the check only in `snoozeAllEvents()` and `snoozeAllCollapsedEvents()`, explicit selection always works, but "snooze everything" respects pinning. + +### Pin icon layout: coexisting with mute, task, alarm icons + +The event card layout (`event_card_compact.xml`) has a horizontal `LinearLayout` aligned to the bottom-right that already holds up to 3 icons: mute, task, alarm. Each is `wrap_content` with `scaleX/Y="0.75"` and `visibility="gone"` by default. + +Adding pin as a 4th icon in this same LinearLayout works cleanly: +- Icons flow horizontally, each ~18dp effective width at 0.75 scale +- Worst case (all 4 visible): ~72dp total — fits comfortably in the card width +- Pin icon goes **first** in the row (leftmost) since it's the most user-relevant status indicator +- Order: pin, mute, task, alarm + +When an event is both pinned and muted, both icons show side by side — no special layout handling needed. + +### Pin/unpin mechanisms: icon toggle + overflow menu + +**Individual toggle:** The pin icon on the event card acts as both indicator and toggle — single tap pins/unpins. Same interaction pattern the user already understands from the list. + +**Batch pin via overflow menu:** Following the exact pattern of "Mute all visible" (`R.id.action_mute_all`): +- Add "Pin all visible" (`R.id.action_pin_all`) to `main.xml` overflow menu +- Show only on Active tab (same as mute all), only when there are unpinned events +- Confirmation dialog → `ApplicationController.pinAllVisibleEvents()` +- Also add "Unpin all visible" (`R.id.action_unpin_all`) for the reverse operation +- Both skip `isNotSpecial`/task events to match mute behavior + +The ViewEventActivityNoRecents popup menu also gets Pin/Unpin items, following the exact Mute/Un-Mute pattern in `showDismissEditPopup()`. + +### Filter pill: PINNED as StatusOption on Active + Upcoming + +`StatusOption` is multi-select with OR logic. Adding `PINNED` means: +- Select only PINNED → shows pinned events +- Select PINNED + SNOOZED → shows events that are pinned OR snoozed +- Select nothing → shows all events (existing behavior) + +This integrates cleanly with the existing `FilterState.matchesStatus()` without special-casing. + +**Per-tab availability:** The status filter popup in `showStatusFilterPopup()` already gates options by tab (Upcoming excludes SNOOZED/ACTIVE). PINNED is relevant on both tabs: +- **Active tab:** All options including PINNED +- **Upcoming tab:** Muted, Recurring, PINNED (Snoozed/Active still excluded — don't apply to unfired events) +- **Dismissed tab:** Still no Status chip (no change) + +## Implementation Plan + +### Phase 1: Storage — `IS_PINNED` Flag + +**Goal:** Add the pinned flag to the domain model. + +**Changes:** +- `EventAlertRecord.kt`: Add `IS_PINNED = 8L` to `EventAlertFlags`, add `isPinned` computed property (same pattern as `isMuted`) +- No changes to `EventAlertEntity.kt` or Room schema — `flags`/`i1` column already exists and maps correctly + +**Verification:** Unit test that `isPinned` reads/writes bit 3 correctly, doesn't interfere with other flags, and round-trips through `EventAlertEntity`. + +### Phase 2: Batch Exclusion — Snooze/Mute All Skips Pinned + +**Goal:** Pinned events are excluded from all batch operations. + +**Changes:** +- `ApplicationController.kt`: Add `!event.isPinned` to the filter in: + - `snoozeAllEvents()` — before search/filterState checks + - `snoozeAllCollapsedEvents()` — alongside the existing displayStatus check + - `muteAllVisibleEvents()` — alongside the existing snoozedUntil/isNotSpecial/isTask checks +- `snoozeSelectedEvents()` — **no change** (explicit selection overrides pin) + +**Verification:** Tests confirming: +- Pinned events survive snooze-all while unpinned events get snoozed +- Pinned events survive snooze-all-collapsed +- Pinned events survive mute-all +- Pinned events ARE snoozed when explicitly selected via `snoozeSelectedEvents` +- Notification-path snooze-all (no search/filter) still excludes pinned + +### Phase 3: Event List UI — Pin Indicator + Toggle + +**Goal:** Users can see which events are pinned and toggle pin state from the event list. + +**Changes:** +- `event_card_compact.xml`: Add `imageview_is_pinned_indicator` as first child in the icon row LinearLayout (before mute, task, alarm). Same sizing pattern: `wrap_content`, `scaleX/Y="0.75"`, `visibility="gone"`, Material `ic_push_pin` drawable. +- `EventListAdapter.kt`: + - Add `pinImage` ViewHolder field for `imageview_is_pinned_indicator` + - Set visibility based on `event.isPinned` (same pattern as `muteImage` / `event.isMuted`) + - Set click listener on pin icon to invoke a callback for toggling +- `ActiveEventsFragment.kt`: Handle pin toggle callback — load event from `EventsStorage`, toggle `isPinned`, save back, refresh list +- Pin icon drawable: Material `ic_push_pin_24dp` tinted with `@color/primary_text` + +**Verification:** Manual testing — pin icon appears for pinned events, tapping toggles state, list refreshes. Both pin + mute icons visible simultaneously when applicable. + +### Phase 3.5: ViewEventActivity Popup Menu — Pin / Unpin + +**Goal:** Users can pin/unpin individual events from the event detail view popup menu, same pattern as Mute/Un-Mute. + +**Changes:** +- `snooze.xml` (menu): Add `action_pin_event` (visible when not pinned and not a task) and `action_unpin_event` (visible when pinned) +- `ViewEventActivityNoRecents.kt`: In `showDismissEditPopup()`, set visibility based on `event.isPinned` / `event.isTask`. Handle click to toggle pin in storage and update local state. +- `strings.xml`: Add `pin_notification` and `unpin_notification` strings + +**Verification:** Pin/Unpin items appear correctly based on event state. Tapping toggles the pin and closes the popup. + +### Phase 4: Overflow Menu — Pin All / Unpin All + +**Goal:** Batch pin/unpin from the overflow menu, same pattern as "Mute all visible". + +**Changes:** +- `main.xml` (menu): Add `action_pin_all` and `action_unpin_all` menu items (same `orderInCategory` region as mute, `showAsAction="never"`, `visible="false"`) +- `MainActivityModern.kt`: + - `onCreateOptionsMenu()`: Show `action_pin_all` on Active tab when there are unpinned visible events; show `action_unpin_all` when there are pinned events. Follow the exact pattern of `action_mute_all` visibility gating. + - `onOptionsItemSelected()`: Wire up `onPinAll()` / `onUnpinAll()` with confirmation dialogs (same pattern as `onMuteAll()`) + - `doPinAll()` / `doUnpinAll()`: Call `ApplicationController.pinAllVisibleEvents()` / `unpinAllVisibleEvents()` +- `ApplicationController.kt`: Add `pinAllVisibleEvents()` and `unpinAllVisibleEvents()` following the exact pattern of `muteAllVisibleEvents()` — load events, filter to visible non-special, set `isPinned`, update, notify +- `SearchableFragment.kt`: Add `supportsPinAll()` and `anyForPinAll()` / `anyForUnpinAll()` (return true for Active tab, false for others) +- `strings.xml`: Add `pin_all`, `unpin_all`, `pin_all_events_question`, `unpin_all_events_question` + +**Verification:** Pin All sets `isPinned` on all visible non-special events. Unpin All clears it. Menu items appear/hide correctly based on pin state. + +### Phase 5: Filter Pill — PINNED Status Option + +**Goal:** Users can filter the Active and Upcoming event lists to show only pinned events. + +**Changes:** +- `FilterState.kt` / `StatusOption`: Add `PINNED` enum value with `matches` = `event.isPinned` +- `FilterState.toDisplayString()`: Add display string for PINNED +- `MainActivityModern.kt`: + - `showStatusFilterPopup()`: PINNED appears on both Active and Upcoming tabs (it is NOT in the Upcoming exclusion list alongside SNOOZED/ACTIVE) + - `StatusOption.toDisplayString()`: Add PINNED case +- `strings.xml`: Add `filter_status_pinned` string resource + +**Verification:** Selecting PINNED in status filter shows only pinned events on both Active and Upcoming tabs. Combining with other status options works (OR logic). + +### Phase 6: Snooze All Confirmation — "Excluding N Pinned" + +**Goal:** The SnoozeAllActivity confirmation clearly communicates that pinned events are excluded. + +**Changes:** +- `MainActivityModern.kt`: When launching SnoozeAllActivity, pass an additional `INTENT_PINNED_EVENT_COUNT` extra with the count of pinned events in the current view +- `Consts.kt`: Add `INTENT_PINNED_EVENT_COUNT` constant +- `SnoozeAllActivity.kt`: + - Read `pinnedCount` from intent + - In `getConfirmationMessage()`: When `pinnedCount > 0`, append "\n(N pinned event(s) excluded)" to the confirmation message + - In the count display: Show snoozable count (total displayed minus pinned) as the primary number +- `strings.xml`: Add `pinned_events_excluded` plurals string — e.g. "(%1$d pinned event excluded)" / "(%1$d pinned events excluded)" + +**Example confirmation messages:** +- No pinned: "Snooze all notifications?" (unchanged) +- 2 pinned: "Snooze all notifications?\n(2 pinned events excluded)" +- With filters + pinned: "Snooze all events matching Snoozed filter?\n(1 pinned event excluded)" + +**Verification:** Confirmation dialog shows correct pinned exclusion count. Count matches reality. + +### Milestone Checkpoint + +After Phase 6, the complete pinning feature works end-to-end: +- Users can pin/unpin individual events from the event list icon +- Users can batch pin/unpin via overflow menu +- Pinned events are visually indicated with a pin icon +- Snooze All / Change All / Snooze All Collapsed / Mute All skip pinned events +- Users can filter to see only pinned events (Active + Upcoming tabs) +- Snooze All confirmation transparently shows pinned exclusion count + +## Files to Modify/Create + +### New Files + +| File | Purpose | +|------|---------| +| Pin icon drawable(s) | Material push_pin icon (filled + outlined, or single with visibility toggle) | +| Tests (see Testing Plan) | Robolectric tests for flag, batch exclusion, filter | + +### Modified Files + +| File | Phase | Changes | +|------|-------|---------| +| `EventAlertRecord.kt` | 1 | Add `IS_PINNED = 8L`, `isPinned` property | +| `ApplicationController.kt` | 2, 4 | Add `!event.isPinned` to batch snooze/mute filters; add `pinAllVisibleEvents()` / `unpinAllVisibleEvents()` | +| `event_card_compact.xml` | 3 | Add `imageview_is_pinned_indicator` as first child in icon row | +| `EventListAdapter.kt` | 3 | Pin indicator visibility + tap handler | +| `ActiveEventsFragment.kt` | 3 | Pin toggle callback handler | +| `main.xml` (menu) | 4 | Add `action_pin_all` and `action_unpin_all` items | +| `MainActivityModern.kt` | 4, 5 | Pin all/unpin all menu handling; PINNED in status filter popup; pass pinned count to SnoozeAllActivity | +| `SearchableFragment.kt` | 4 | Add `supportsPinAll()`, `anyForPinAll()`, `anyForUnpinAll()` | +| `FilterState.kt` / `StatusOption` | 5 | Add `PINNED` enum value | +| `Consts.kt` | 6 | Add `INTENT_PINNED_EVENT_COUNT` | +| `SnoozeAllActivity.kt` | 6 | Read pinned count, show "(N pinned excluded)" in confirmation | +| `strings.xml` | 3, 4, 5, 6 | Pin-related string resources | + +## Testing Plan + +### Unit Tests (Robolectric) + +**EventAlertRecord pin flag:** +- `isPinned` defaults to false for new records +- Setting `isPinned = true` sets bit 3 in flags +- `isPinned` does not interfere with `isMuted`, `isTask`, `isAlarm` (all 4 can be true simultaneously) +- Flag round-trips through `EventAlertEntity.fromRecord()` / `toRecord()` + +**Batch exclusion (`ApplicationController`):** +- `snoozeAllEvents` with mix of pinned/unpinned: only unpinned get snoozed +- `snoozeAllEvents` with all pinned: returns null (no events snoozed) +- `snoozeAllEvents` with search query + pinned: pinned excluded even when matching search +- `snoozeAllEvents` with FilterState + pinned: pinned excluded even when matching filter +- `snoozeAllCollapsedEvents` with pinned collapsed event: pinned excluded +- `muteAllVisibleEvents` with pinned visible event: pinned excluded from mute +- `snoozeSelectedEvents` with pinned event in selection: pinned IS snoozed (explicit selection overrides) + +**Batch pin/unpin (`ApplicationController`):** +- `pinAllVisibleEvents` pins all visible non-special non-task events +- `pinAllVisibleEvents` skips already-pinned events (idempotent) +- `unpinAllVisibleEvents` unpins all pinned events +- Neither operation affects snoozed events (matching mute-all behavior) + +**StatusOption.PINNED:** +- `PINNED.matches()` returns true for pinned events, false for unpinned +- `FilterState.matchesStatus()` with PINNED in set: filters correctly +- `FilterState.toDisplayString()` includes "Pinned" when PINNED selected +- `FilterState.toBundle()` / `fromBundle()` round-trips correctly with PINNED in statusFilters + +### Instrumentation Tests + +- Pin flag persists across `EventsStorage` close/reopen (Room round-trip with real SQLite) +- Pin toggle from event list updates storage and refreshes UI + +## Future Enhancements + +1. **Notification "Pin" action** — Add a Pin/Unpin action button on individual event notifications +3. **Dismiss All exclusion** — When Dismiss All gets filter support, exclude pinned events (configurable) +4. **Pin count badge** — Show count of pinned events somewhere in the UI (toolbar, filter chip) +5. **`#pin` hashtag auto-pin** — Integration with [#185](https://github.com/williscool/CalendarNotification/issues/185) extended hashtag system: events with `#pin` in title are automatically pinned +6. **Multi-select pin/unpin** — Add Pin/Unpin action to the multi-select toolbar in ActiveEventsFragment +7. **Pin indicator in Upcoming tab** — Show pin icon on upcoming events (read-only, since upcoming events use MonitorStorage not EventsStorage) + +## Notes + +### Backward compatibility + +Existing events have `flags` bit 3 = 0, so `isPinned = false` by default. No migration, no data fixup. Users start with zero pinned events and pin explicitly. + +### Pin state vs. `isNotSpecial` + +`EventAlertRecord.isNotSpecial` (checked in `snoozeEvents` base) is for system-level exclusions. Pinning is user-controlled and checked in the caller-specific filter lambdas (`snoozeAllEvents`, `snoozeAllCollapsedEvents`), keeping the two concerns separate. + +### Pin icon placement on event card + +The icon row LinearLayout in `event_card_compact.xml` already handles multiple icons (mute, task, alarm) as `wrap_content` with `visibility="gone"`. Adding pin as the first child means the icon order is: pin, mute, task, alarm. At `scaleX/Y="0.75"` each icon is ~18dp wide, so even with all 4 visible (~72dp) there's no overflow concern. + +### Pin All / Unpin All menu pattern + +Follows the exact pattern established by "Mute all visible": +- Menu item defined in `main.xml` with `visible="false"` +- `onCreateOptionsMenu()` gates visibility based on fragment support + whether there are eligible events +- Click handler shows confirmation `AlertDialog`, then calls `ApplicationController` method +- Fragment exposes `supportsPinAll()` / `anyForPinAll()` / `anyForUnpinAll()` through `SearchableFragment` interface + +## Related Work + +- [Milestone 3: Filter Pills](./event_lookahead_milestone3_filter_pills.md) — The filter infrastructure this builds on +- [Snooze All with Filter Pills](./snooze_all_filter_pills.md) — How FilterState integrates with batch snooze +- [Multi-select batch operations](./multi_select_batch_operations.md) — `snoozeSelectedEvents` that should NOT exclude pinned +- [Extended hashtag system (#185)](https://github.com/williscool/CalendarNotification/issues/185) — Future `#pin` auto-pin integration