Skip to content

Commit 9c1a1b2

Browse files
authored
feat: event pin 186 (#266)
* docs: plan for event pinning init commit * docs: plan update * feat: draw phase 1 through 4 of the owl * fix: missed spot * fix: it actually works! * test: tests * feat: phase 5 and 6 * fix: batch pinning * fix: bug * feat: change all excludes pinnned events * fix: bug and test * feat: filter for unpinned
1 parent 33513f5 commit 9c1a1b2

24 files changed

Lines changed: 1198 additions & 14 deletions

android/app/src/androidTest/java/com/github/quarck/calnotify/app/ApplicationControllerCoreTest.kt

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,5 +341,86 @@ class ApplicationControllerCoreTest {
341341
}
342342
assertFalse("Task event should NOT be muted", updatedEvent?.isMuted == true)
343343
}
344+
345+
// === muteAllVisibleEvents does NOT skip pinned (pinning only affects batch snooze) ===
346+
347+
@Test
348+
fun testMuteAllVisibleEvents_mutesPinnedEvents() {
349+
DevLog.info(LOG_TAG, "Running testMuteAllVisibleEvents_mutesPinnedEvents")
350+
351+
val pinnedEventId = 100040L
352+
val normalEventId = 100041L
353+
val pinnedEvent = createTestEvent(eventId = pinnedEventId, isMuted = false).apply { isPinned = true }
354+
val normalEvent = createTestEvent(eventId = normalEventId, isMuted = false)
355+
EventsStorage(context).use { db ->
356+
db.addEvent(pinnedEvent)
357+
db.addEvent(normalEvent)
358+
}
359+
360+
ApplicationController.muteAllVisibleEvents(context)
361+
362+
EventsStorage(context).use { db ->
363+
val updatedPinned = db.getEvent(pinnedEventId, baseTime)
364+
val updatedNormal = db.getEvent(normalEventId, baseTime)
365+
assertTrue("Pinned event should be muted (pinning only affects batch snooze)", updatedPinned?.isMuted == true)
366+
assertTrue("Normal event should be muted", updatedNormal?.isMuted == true)
367+
}
368+
}
369+
370+
// === pinAllVisibleEvents / unpinAllVisibleEvents ===
371+
372+
@Test
373+
fun testPinAllVisibleEvents_pinsVisibleEvents() {
374+
DevLog.info(LOG_TAG, "Running testPinAllVisibleEvents_pinsVisibleEvents")
375+
376+
val eventId = 100042L
377+
val event = createTestEvent(eventId = eventId)
378+
EventsStorage(context).use { db ->
379+
db.addEvent(event)
380+
}
381+
382+
ApplicationController.pinAllVisibleEvents(context)
383+
384+
val updatedEvent = EventsStorage(context).use { db ->
385+
db.getEvent(eventId, baseTime)
386+
}
387+
assertTrue("Visible event should be pinned", updatedEvent?.isPinned == true)
388+
}
389+
390+
@Test
391+
fun testPinAllVisibleEvents_skipsSnoozedEvents() {
392+
DevLog.info(LOG_TAG, "Running testPinAllVisibleEvents_skipsSnoozedEvents")
393+
394+
val eventId = 100043L
395+
val event = createTestEvent(eventId = eventId, snoozedUntil = baseTime + 3600000L)
396+
EventsStorage(context).use { db ->
397+
db.addEvent(event)
398+
}
399+
400+
ApplicationController.pinAllVisibleEvents(context)
401+
402+
val updatedEvent = EventsStorage(context).use { db ->
403+
db.getEvent(eventId, baseTime)
404+
}
405+
assertFalse("Snoozed event should NOT be pinned", updatedEvent?.isPinned == true)
406+
}
407+
408+
@Test
409+
fun testUnpinAllVisibleEvents_unpinsAll() {
410+
DevLog.info(LOG_TAG, "Running testUnpinAllVisibleEvents_unpinsAll")
411+
412+
val eventId = 100044L
413+
val event = createTestEvent(eventId = eventId).apply { isPinned = true }
414+
EventsStorage(context).use { db ->
415+
db.addEvent(event)
416+
}
417+
418+
ApplicationController.unpinAllVisibleEvents(context)
419+
420+
val updatedEvent = EventsStorage(context).use { db ->
421+
db.getEvent(eventId, baseTime)
422+
}
423+
assertFalse("Event should be unpinned", updatedEvent?.isPinned == true)
424+
}
344425
}
345426

android/app/src/androidTest/java/com/github/quarck/calnotify/app/SnoozeTest.kt

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,5 +376,65 @@ class SnoozeTest {
376376
assertTrue("Not snoozed event should be snoozed", updatedNot!!.snoozedUntil > 0)
377377
}
378378
}
379+
380+
// === Pinned exclusion from snoozeAll ===
381+
382+
@Test
383+
fun testSnoozeAllEvents_skipsPinnedEvents() {
384+
DevLog.info(LOG_TAG, "Running testSnoozeAllEvents_skipsPinnedEvents")
385+
386+
val pinnedEvent = createTestEvent(eventId = 600L, title = "Pinned").apply { isPinned = true }
387+
val normalEvent = createTestEvent(eventId = 601L, title = "Normal")
388+
addEventToStorage(pinnedEvent)
389+
addEventToStorage(normalEvent)
390+
391+
val snoozeDelay = 30 * 60 * 1000L
392+
393+
val result = ApplicationController.snoozeAllEvents(
394+
context, snoozeDelay, isChange = false, onlySnoozeVisible = false
395+
)
396+
397+
assertNotNull("Snooze result should not be null", result)
398+
399+
EventsStorage(context).use { db ->
400+
val updatedPinned = db.getEvent(pinnedEvent.eventId, pinnedEvent.instanceStartTime)
401+
val updatedNormal = db.getEvent(normalEvent.eventId, normalEvent.instanceStartTime)
402+
403+
assertEquals("Pinned event should NOT be snoozed", 0L, updatedPinned!!.snoozedUntil)
404+
assertTrue("Normal event should be snoozed", updatedNormal!!.snoozedUntil > 0)
405+
}
406+
}
407+
408+
@Test
409+
fun testSnoozeAllCollapsedEvents_skipsPinnedEvents() {
410+
DevLog.info(LOG_TAG, "Running testSnoozeAllCollapsedEvents_skipsPinnedEvents")
411+
412+
val pinnedCollapsed = createTestEvent(
413+
eventId = 700L,
414+
displayStatus = EventDisplayStatus.DisplayedCollapsed
415+
).apply { isPinned = true }
416+
val normalCollapsed = createTestEvent(
417+
eventId = 701L,
418+
displayStatus = EventDisplayStatus.DisplayedCollapsed
419+
)
420+
addEventToStorage(pinnedCollapsed)
421+
addEventToStorage(normalCollapsed)
422+
423+
val snoozeDelay = 30 * 60 * 1000L
424+
425+
val result = ApplicationController.snoozeAllCollapsedEvents(
426+
context, snoozeDelay, isChange = false, onlySnoozeVisible = false
427+
)
428+
429+
assertNotNull("Snooze result should not be null", result)
430+
431+
EventsStorage(context).use { db ->
432+
val updatedPinned = db.getEvent(pinnedCollapsed.eventId, pinnedCollapsed.instanceStartTime)
433+
val updatedNormal = db.getEvent(normalCollapsed.eventId, normalCollapsed.instanceStartTime)
434+
435+
assertEquals("Pinned collapsed event should NOT be snoozed", 0L, updatedPinned!!.snoozedUntil)
436+
assertTrue("Normal collapsed event should be snoozed", updatedNormal!!.snoozedUntil > 0)
437+
}
438+
}
379439
}
380440

android/app/src/main/java/com/github/quarck/calnotify/Consts.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ object Consts {
7373
const val INTENT_SEARCH_QUERY = "search_query"
7474
const val INTENT_SEARCH_QUERY_EVENT_COUNT = "search_query_event_count"
7575
const val INTENT_FILTER_STATE = "filter_state"
76+
const val INTENT_PINNED_EVENT_COUNT = "pinned_event_count"
7677
const val INTENT_SNOOZE_ALL_COLLAPSED_KEY = "snooze_all_collapsed"
7778
const val INTENT_DISMISS_ALL_KEY = "dismiss_all"
7879

android/app/src/main/java/com/github/quarck/calnotify/app/ApplicationController.kt

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -930,7 +930,7 @@ object ApplicationController : ApplicationControllerInterface, EventMovedHandler
930930
}
931931

932932
fun snoozeAllCollapsedEvents(context: Context, snoozeDelay: Long, isChange: Boolean, onlySnoozeVisible: Boolean): SnoozeResult? {
933-
return snoozeEvents(context, { it.displayStatus == EventDisplayStatus.DisplayedCollapsed }, snoozeDelay, isChange, onlySnoozeVisible)
933+
return snoozeEvents(context, { it.displayStatus == EventDisplayStatus.DisplayedCollapsed && !it.isPinned }, snoozeDelay, isChange, onlySnoozeVisible)
934934
}
935935

936936
fun snoozeAllEvents(
@@ -943,20 +943,19 @@ object ApplicationController : ApplicationControllerInterface, EventMovedHandler
943943
): SnoozeResult? {
944944
val now = clock.currentTimeMillis()
945945
return snoozeEvents(context, { event ->
946+
// Pinned events are excluded from batch snooze
947+
!event.isPinned &&
946948
// Search query filter (existing behavior)
947-
val matchesSearch = searchQuery?.let { query ->
949+
(searchQuery?.let { query ->
948950
event.title.contains(query, ignoreCase = true) ||
949951
event.desc.contains(query, ignoreCase = true)
950-
} ?: true
951-
952-
// FilterState filters (new)
953-
val matchesFilter = filterState?.let { filter ->
952+
} ?: true) &&
953+
// FilterState filters
954+
(filterState?.let { filter ->
954955
filter.matchesCalendar(event) &&
955956
filter.matchesStatus(event) &&
956957
filter.matchesTime(event, now)
957-
} ?: true
958-
959-
matchesSearch && matchesFilter
958+
} ?: true)
960959
}, snoozeDelay, isChange, onlySnoozeVisible)
961960
}
962961

@@ -1147,6 +1146,28 @@ object ApplicationController : ApplicationControllerInterface, EventMovedHandler
11471146
}
11481147
}
11491148

1149+
fun pinAllVisibleEvents(context: Context) {
1150+
getEventsStorage(context).use { db ->
1151+
val eventsToPin = db.events.filter {
1152+
event -> (event.snoozedUntil == 0L) && event.isNotSpecial && !event.isPinned
1153+
}
1154+
if (eventsToPin.isNotEmpty()) {
1155+
val pinnedEvents = eventsToPin.map { it.isPinned = true; it }
1156+
db.updateEvents(pinnedEvents)
1157+
}
1158+
}
1159+
}
1160+
1161+
fun unpinAllVisibleEvents(context: Context) {
1162+
getEventsStorage(context).use { db ->
1163+
val eventsToUnpin = db.events.filter { event -> event.isPinned }
1164+
if (eventsToUnpin.isNotEmpty()) {
1165+
val unpinnedEvents = eventsToUnpin.map { it.isPinned = false; it }
1166+
db.updateEvents(unpinnedEvents)
1167+
}
1168+
}
1169+
}
1170+
11501171
fun dismissEvent(
11511172
context: Context,
11521173
db: EventsStorageInterface,

android/app/src/main/java/com/github/quarck/calnotify/calendar/EventAlertRecord.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ object EventAlertFlags {
9999
const val IS_MUTED = 1L
100100
const val IS_TASK = 2L
101101
const val IS_ALARM = 4L
102+
const val IS_PINNED = 8L
102103
}
103104

104105
fun Long.isFlagSet(flag: Long)
@@ -166,6 +167,10 @@ data class EventAlertRecord(
166167
get() = flags.isFlagSet(EventAlertFlags.IS_ALARM)
167168
set(value) { flags = flags.setFlag(EventAlertFlags.IS_ALARM, value) }
168169

170+
var isPinned: Boolean
171+
get() = flags.isFlagSet(EventAlertFlags.IS_PINNED)
172+
set(value) { flags = flags.setFlag(EventAlertFlags.IS_PINNED, value) }
173+
169174
val isUnmutedAlarm: Boolean
170175
get() = isAlarm && !isMuted
171176

android/app/src/main/java/com/github/quarck/calnotify/ui/ActiveEventsFragment.kt

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,23 @@ class ActiveEventsFragment : Fragment(), EventListCallback, SearchableFragment,
350350
updateEmptyState()
351351
}
352352

353+
override fun onPinToggle(event: EventAlertRecord) {
354+
val ctx = context ?: return
355+
background {
356+
getEventsStorage(ctx).use { db ->
357+
val current = db.getEvent(event.eventId, event.instanceStartTime)
358+
if (current != null) {
359+
current.isPinned = !current.isPinned
360+
db.updateEvent(current)
361+
}
362+
}
363+
activity?.runOnUiThread {
364+
loadEvents()
365+
activity?.invalidateOptionsMenu()
366+
}
367+
}
368+
}
369+
353370
override fun onScrollPositionChange(newPos: Int) {
354371
// Not needed for fragments - handled within adapter if needed
355372
}
@@ -371,6 +388,8 @@ class ActiveEventsFragment : Fragment(), EventListCallback, SearchableFragment,
371388

372389
override fun hasActiveEvents(): Boolean = adapter.hasActiveEvents
373390

391+
override fun hasUnpinnedActiveEvents(): Boolean = adapter.hasUnpinnedActiveEvents
392+
374393
override fun supportsSnoozeAll(): Boolean = true
375394

376395
override fun supportsMuteAll(): Boolean = true
@@ -381,6 +400,14 @@ class ActiveEventsFragment : Fragment(), EventListCallback, SearchableFragment,
381400

382401
override fun anyForDismissAll(): Boolean = adapter.anyForDismissAllButRecentAndSnoozed
383402

403+
override fun supportsPinAll(): Boolean = true
404+
405+
override fun anyForPinAll(): Boolean = adapter.anyForPinAll
406+
407+
override fun anyForUnpinAll(): Boolean = adapter.anyForUnpinAll
408+
409+
override fun getPinnedEventCount(): Int = adapter.pinnedCount
410+
384411
override fun onMuteAllComplete() {
385412
loadEvents()
386413
}
@@ -389,6 +416,10 @@ class ActiveEventsFragment : Fragment(), EventListCallback, SearchableFragment,
389416
loadEvents()
390417
}
391418

419+
override fun onPinAllComplete() {
420+
loadEvents()
421+
}
422+
392423
override fun onFilterChanged() {
393424
loadEvents()
394425
}

android/app/src/main/java/com/github/quarck/calnotify/ui/EventListAdapter.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ interface EventListCallback {
5050
fun onItemRemoved(event: EventAlertRecord)
5151
fun onItemRestored(event: EventAlertRecord) // e.g. undo
5252
fun onScrollPositionChange(newPos: Int)
53+
fun onPinToggle(event: EventAlertRecord) {}
5354
}
5455

5556
interface SelectionModeCallback {
@@ -91,6 +92,7 @@ class EventListAdapter(
9192

9293
var undoButton: Button?
9394

95+
var pinImage: ImageView?
9496
var muteImage: ImageView?
9597
var taskImage: ImageView?
9698
val alarmImage: ImageView?
@@ -114,6 +116,7 @@ class EventListAdapter(
114116

115117
undoButton = itemView.find<Button?>(R.id.card_view_button_undo)
116118

119+
pinImage = itemView.find<ImageView?>(R.id.imageview_is_pinned_indicator)
117120
muteImage = itemView.find<ImageView?>(R.id.imageview_is_muted_indicator)
118121
taskImage = itemView.find<ImageView?>(R.id.imageview_is_task_indicator)
119122
alarmImage = itemView.find<ImageView?>(R.id.imageview_is_alarm_indicator)
@@ -370,6 +373,12 @@ class EventListAdapter(
370373

371374
holder.eventTitleText.text = event.titleAsOneLine
372375

376+
holder.pinImage?.visibility = if (event.isPinned) View.VISIBLE else View.GONE
377+
holder.pinImage?.setOnClickListener {
378+
val ev = getEventAtPosition(holder.adapterPosition)
379+
if (ev != null) callback.onPinToggle(ev)
380+
}
381+
373382
holder.muteImage?.visibility = if (event.isMuted) View.VISIBLE else View.GONE
374383

375384
holder.taskImage?.visibility = if (event.isTask) View.VISIBLE else View.GONE
@@ -430,6 +439,9 @@ class EventListAdapter(
430439
val hasActiveEvents: Boolean
431440
get() = events.any { it.snoozedUntil == 0L }
432441

442+
val hasUnpinnedActiveEvents: Boolean
443+
get() = events.any { it.snoozedUntil == 0L && !it.isPinned }
444+
433445
fun setSearchText(query: String?) {
434446
currentSearchString = query
435447
setEventsToDisplay()
@@ -589,6 +601,15 @@ class EventListAdapter(
589601
val anyForMute: Boolean
590602
get() = events.any { it.snoozedUntil == 0L && it.isNotSpecial}
591603

604+
val anyForPinAll: Boolean
605+
get() = events.any { it.snoozedUntil == 0L && it.isNotSpecial && !it.isPinned }
606+
607+
val anyForUnpinAll: Boolean
608+
get() = events.any { it.isPinned }
609+
610+
val pinnedCount: Int
611+
get() = events.count { it.isPinned }
612+
592613
// === Selection Mode Methods ===
593614

594615
fun enterSelectionMode(firstEvent: EventAlertRecord) {

android/app/src/main/java/com/github/quarck/calnotify/ui/FilterState.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,8 @@ data class FilterState(
184184
StatusOption.ACTIVE -> context.getString(R.string.filter_status_active)
185185
StatusOption.MUTED -> context.getString(R.string.filter_status_muted)
186186
StatusOption.RECURRING -> context.getString(R.string.filter_status_recurring)
187+
StatusOption.PINNED -> context.getString(R.string.filter_status_pinned)
188+
StatusOption.UNPINNED -> context.getString(R.string.filter_status_unpinned)
187189
}
188190
}
189191
parts.add(names.joinToString(", "))
@@ -275,14 +277,16 @@ data class FilterState(
275277
* Individual status filter options. Multiple can be selected (OR logic).
276278
*/
277279
enum class StatusOption {
278-
SNOOZED, ACTIVE, MUTED, RECURRING;
280+
SNOOZED, ACTIVE, MUTED, RECURRING, PINNED, UNPINNED;
279281

280282
/** Check if an event matches this specific option */
281283
fun matches(event: EventAlertRecord): Boolean = when (this) {
282284
SNOOZED -> event.snoozedUntil > 0
283285
ACTIVE -> event.snoozedUntil == 0L
284286
MUTED -> event.isMuted
285287
RECURRING -> event.isRepeating
288+
PINNED -> event.isPinned
289+
UNPINNED -> !event.isPinned
286290
}
287291
}
288292

0 commit comments

Comments
 (0)