Skip to content

Commit 25be2cb

Browse files
feat: calendar multi select (#227)
* docs: Add multi-select batch operations plan Addresses GitHub issue #182 - Feature: Multi-select events for batch snooze operations. The plan covers: - UX flow for entering/exiting selection mode via long-press - Selection state management in EventListAdapter - Integration with existing search and filter chips - Technical design for UI components and ApplicationController changes - Implementation phases broken into 5 stages - Testing strategy including unit, Robolectric, and instrumentation tests Co-authored-by: wharris <wharris@upscalews.com> * docs: Clarify multi-select scope and add future enhancements section - Clarify that initial implementation is Active tab only in Modern UI - Add explicit Scope section listing what's in/out of scope - Remove Upcoming/Dismissed from open questions (moved to future) - Add Future Enhancements section covering: - Upcoming tab: batch pre-snooze, pre-dismiss, pre-mute - Dismissed tab: batch restore - Shared selection infrastructure for code reuse Co-authored-by: wharris <wharris@upscalews.com> * docs: Clarify Snooze All/Dismiss All filtering differences - Note that only Snooze All/Change All supports search/filter integration - Dismiss All does NOT support filtering (dismisses all non-recent events) - Multi-select provides first way to batch dismiss specific events - Add 'Relationship to Existing Features' section in Scope - Note that multi-select dismiss ignores recency threshold Co-authored-by: wharris <wharris@upscalews.com> * docs: Remove batch dismiss from initial scope, add to future enhancements Multi-select will initially only support batch snooze/change operations. Batch dismiss is moved to future enhancements, to be implemented alongside batch restore on the Dismissed tab for a complete workflow. Changes: - Remove dismiss from in-scope features - Update UI mockups to only show snooze button - Remove dismissSelectedEvents from ApplicationController examples - Remove dismiss-related string resources - Add 'Active Events Tab - Batch Dismiss' to Future Enhancements - Note that batch dismiss should pair with batch restore Co-authored-by: wharris <wharris@upscalews.com> * feat: Add multi-select for batch snooze operations Implements GitHub issue #182 - Multi-select events for batch snooze. Changes: - EventListAdapter: Add selection mode state, SelectionModeCallback interface, selection methods (enterSelectionMode, exitSelectionMode, toggleSelection, selectAllVisible, getSelectedEvents), checkbox binding, disable swipe in selection mode - event_card_compact.xml: Add hidden checkbox for selection indicator - fragment_event_list.xml: Add selection action bar (top) with close button, count text, select all; add bottom bar with snooze button - ActiveEventsFragment: Implement SelectionModeCallback, handle long-press to enter selection mode, setup selection UI, back press handling - MainActivityModern: Add onSelectionModeChanged to hide toolbar/FAB/chips - ApplicationController: Add snoozeSelectedEvents method for batch snooze - SnoozeAllActivity: Support multi-select mode via INTENT_SELECTED_EVENT_KEYS - strings.xml: Add multi-select related strings Features: - Long-press event card to enter selection mode - Tap cards to toggle selection - Select All button selects visible events - Selection persists through filter/search changes - Hidden selected count shows when events filtered out - Snooze Selected opens snooze dialog for selected events only Co-authored-by: wharris <wharris@upscalews.com> * test: Add tests for multi-select functionality Add Robolectric tests for: - EventListAdapterSelectionTest: Tests for selection mode state management, enterSelectionMode, exitSelectionMode, toggleSelection, selectAllVisible, selection persistence through filter changes, hidden count calculations - ActiveEventsFragmentRobolectricTest: Tests for selection UI visibility - ApplicationControllerCoreRobolectricTest: Tests for snoozeSelectedEvents including selected-only snooze, empty set handling, invalid keys, isChange behavior Co-authored-by: wharris <wharris@upscalews.com> * fix: Use @testing-library/react instead of deprecated react-hooks @testing-library/react-hooks is deprecated and doesn't fully support React 18+. The renderHook and act functions are now built into @testing-library/react since version 13.1+. This fixes flaky test failures due to act() environment warnings. Co-authored-by: wharris <wharris@upscalews.com> * fix: Implement onItemLongClick in all EventListCallback implementors MainActivityLegacy and UpcomingEventsFragment now implement the onItemLongClick method added to EventListCallback interface. Both return false as multi-select is only for Active events in Modern UI. Co-authored-by: wharris <wharris@upscalews.com> * fix: Remove invalid isTask and isMuted params from test EventAlertRecord doesn't have isTask or isMuted as constructor params. isMuted is derived from flags field. Co-authored-by: wharris <wharris@upscalews.com> * fix: Use correct isSpecial criteria in selection tests isSpecial is determined by instanceStartTime == Long.MAX_VALUE, not by negative eventId. Updated tests to use the correct criteria. Co-authored-by: wharris <wharris@upscalews.com> * style: Improve multi-select snooze button and labels - Make snooze button more prominent (full width, tinted background) - Update titles to say 'selected events' for clarity - Distinguishes multi-select from filter-based snooze all Co-authored-by: wharris <wharris@upscalews.com> * feat: Show filter context in multi-select snooze dialog When both multi-select and filters are active, the snooze dialog now shows: '2 selected from 5 matching: filter description' This helps users understand the context of their selection relative to the active filters/search. Co-authored-by: wharris <wharris@upscalews.com> * fix: Exit selection mode when switching tabs When switching tabs while in multi-select mode, the toolbar and FAB were not being restored. Now we properly exit selection mode and restore the UI when navigating away from the Active tab. Co-authored-by: wharris <wharris@upscalews.com> * fix: Add long-click listeners to date/time text views Date and time text views had click listeners that consumed touch events, preventing long-press from triggering selection mode. Now long-press works consistently across the entire event card. Co-authored-by: wharris <wharris@upscalews.com> * fix: to bundle --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 5562d89 commit 25be2cb

15 files changed

Lines changed: 1745 additions & 54 deletions

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -958,6 +958,40 @@ object ApplicationController : ApplicationControllerInterface, EventMovedHandler
958958
matchesSearch && matchesFilter
959959
}, snoozeDelay, isChange, onlySnoozeVisible)
960960
}
961+
962+
/**
963+
* Snooze a specific set of selected events.
964+
*
965+
* @param context The context
966+
* @param eventKeys Set of event keys in format "eventId:instanceStartTime"
967+
* @param snoozeDelay The snooze delay in milliseconds
968+
* @param isChange Whether this is a "change snooze" (all events already snoozed)
969+
* @return SnoozeResult if successful, null otherwise
970+
*/
971+
fun snoozeSelectedEvents(
972+
context: Context,
973+
eventKeys: Set<String>,
974+
snoozeDelay: Long,
975+
isChange: Boolean
976+
): SnoozeResult? {
977+
if (eventKeys.isEmpty()) return null
978+
979+
// Parse event keys into (eventId, instanceStartTime) pairs
980+
val keyPairs = eventKeys.mapNotNull { key ->
981+
val parts = key.split(":")
982+
if (parts.size == 2) {
983+
val eventId = parts[0].toLongOrNull()
984+
val instanceStartTime = parts[1].toLongOrNull()
985+
if (eventId != null && instanceStartTime != null) {
986+
Pair(eventId, instanceStartTime)
987+
} else null
988+
} else null
989+
}.toSet()
990+
991+
return snoozeEvents(context, { event ->
992+
keyPairs.contains(Pair(event.eventId, event.instanceStartTime))
993+
}, snoozeDelay, isChange, false)
994+
}
961995

962996
fun fireEventReminder(
963997
context: Context,

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

Lines changed: 137 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,11 @@ import androidx.core.content.ContextCompat
2828
import android.view.LayoutInflater
2929
import android.view.View
3030
import android.view.ViewGroup
31+
import android.widget.Button
3132
import android.widget.ImageButton
3233
import android.widget.LinearLayout
3334
import android.widget.TextView
35+
import androidx.activity.OnBackPressedCallback
3436
import androidx.fragment.app.Fragment
3537
import androidx.recyclerview.widget.RecyclerView
3638
import androidx.recyclerview.widget.StaggeredGridLayoutManager
@@ -56,7 +58,7 @@ import com.google.android.material.snackbar.Snackbar
5658
* Fragment for displaying active event notifications.
5759
* Migrated from MainActivity's event list functionality.
5860
*/
59-
class ActiveEventsFragment : Fragment(), EventListCallback, SearchableFragment {
61+
class ActiveEventsFragment : Fragment(), EventListCallback, SearchableFragment, SelectionModeCallback {
6062

6163
private lateinit var settings: Settings
6264
private val clock: CNPlusClockInterface get() = getClock()
@@ -67,6 +69,12 @@ class ActiveEventsFragment : Fragment(), EventListCallback, SearchableFragment {
6769
private lateinit var adapter: EventListAdapter
6870
private var newUIBanner: LinearLayout? = null
6971

72+
// Selection mode UI elements
73+
private var selectionActionBar: LinearLayout? = null
74+
private var selectionBottomBar: LinearLayout? = null
75+
private var selectionCountText: TextView? = null
76+
private var backPressedCallback: OnBackPressedCallback? = null
77+
7078
private val dataUpdatedReceiver = object : BroadcastReceiver() {
7179
override fun onReceive(context: Context?, intent: Intent?) {
7280
loadEvents()
@@ -94,6 +102,7 @@ class ActiveEventsFragment : Fragment(), EventListCallback, SearchableFragment {
94102
emptyView.text = getString(R.string.empty_active)
95103

96104
adapter = EventListAdapter(requireContext(), this)
105+
adapter.selectionModeCallback = this
97106
recyclerView.layoutManager = StaggeredGridLayoutManager(1, StaggeredGridLayoutManager.VERTICAL)
98107
recyclerView.adapter = adapter
99108
adapter.recyclerView = recyclerView
@@ -104,6 +113,9 @@ class ActiveEventsFragment : Fragment(), EventListCallback, SearchableFragment {
104113

105114
// Setup new UI banner
106115
setupNewUIBanner(view)
116+
117+
// Setup selection mode UI
118+
setupSelectionModeUI(view)
107119
}
108120

109121
private fun setupNewUIBanner(view: View) {
@@ -128,6 +140,77 @@ class ActiveEventsFragment : Fragment(), EventListCallback, SearchableFragment {
128140
}
129141
}
130142

143+
private fun setupSelectionModeUI(view: View) {
144+
selectionActionBar = view.findViewById(R.id.selection_action_bar)
145+
selectionBottomBar = view.findViewById(R.id.selection_bottom_bar)
146+
selectionCountText = view.findViewById(R.id.selection_count_text)
147+
148+
// Close selection button
149+
view.findViewById<ImageButton>(R.id.btn_close_selection)?.setOnClickListener {
150+
adapter.exitSelectionMode()
151+
}
152+
153+
// Select all button
154+
view.findViewById<TextView>(R.id.btn_select_all)?.setOnClickListener {
155+
adapter.selectAllVisible()
156+
}
157+
158+
// Snooze selected button
159+
view.findViewById<Button>(R.id.btn_snooze_selected)?.setOnClickListener {
160+
showSnoozeSelectedDialog()
161+
}
162+
163+
// Setup back press callback for exiting selection mode
164+
backPressedCallback = object : OnBackPressedCallback(false) {
165+
override fun handleOnBackPressed() {
166+
if (adapter.selectionMode) {
167+
adapter.exitSelectionMode()
168+
}
169+
}
170+
}
171+
requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, backPressedCallback!!)
172+
}
173+
174+
private fun showSnoozeSelectedDialog() {
175+
val selectedEvents = adapter.getSelectedEvents()
176+
if (selectedEvents.isEmpty()) return
177+
178+
val ctx = context ?: return
179+
180+
// Determine if this is a "change" (all snoozed) or "snooze" (some active)
181+
val hasActiveEvents = selectedEvents.any { it.snoozedUntil == 0L }
182+
val isChange = !hasActiveEvents
183+
184+
// Pass selected event keys to SnoozeAllActivity via intent
185+
val eventKeys = selectedEvents.map { "${it.eventId}:${it.instanceStartTime}" }.toTypedArray()
186+
187+
// Get filter/search context for display
188+
val filterState = getFilterState()
189+
val searchQuery = getSearchQuery()
190+
val totalFilteredCount = adapter.itemCount // Total visible (filtered) events
191+
192+
val intent = Intent(ctx, SnoozeAllActivity::class.java)
193+
.putExtra(Consts.INTENT_SNOOZE_ALL_IS_CHANGE, isChange)
194+
.putExtra(Consts.INTENT_SNOOZE_FROM_MAIN_ACTIVITY, true)
195+
.putExtra(INTENT_SELECTED_EVENT_KEYS, eventKeys)
196+
.putExtra(Consts.INTENT_SEARCH_QUERY_EVENT_COUNT, selectedEvents.size)
197+
.putExtra(INTENT_TOTAL_FILTERED_COUNT, totalFilteredCount)
198+
.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
199+
200+
// Pass filter/search info if active
201+
if (!searchQuery.isNullOrEmpty()) {
202+
intent.putExtra(Consts.INTENT_SEARCH_QUERY, searchQuery)
203+
}
204+
if (filterState.hasActiveFilters()) {
205+
intent.putExtra(Consts.INTENT_FILTER_STATE, filterState.toBundle())
206+
}
207+
208+
startActivity(intent)
209+
210+
// Exit selection mode after launching snooze
211+
adapter.exitSelectionMode()
212+
}
213+
131214
private fun dismissBanner() {
132215
settings.showNewUIBanner = false
133216
newUIBanner?.visibility = View.GONE
@@ -239,6 +322,16 @@ class ActiveEventsFragment : Fragment(), EventListCallback, SearchableFragment {
239322
)
240323
}
241324
}
325+
326+
override fun onItemLongClick(v: View, position: Int, eventId: Long): Boolean {
327+
DevLog.info(LOG_TAG, "onItemLongClick, pos=$position, eventId=$eventId")
328+
329+
val event = adapter.getEventAtPosition(position, eventId) ?: return false
330+
if (event.isSpecial) return false
331+
332+
adapter.enterSelectionMode(event)
333+
return true
334+
}
242335

243336
override fun onItemRemoved(event: EventAlertRecord) {
244337
val ctx = context ?: return
@@ -294,10 +387,53 @@ class ActiveEventsFragment : Fragment(), EventListCallback, SearchableFragment {
294387
override fun onFilterChanged() {
295388
loadEvents()
296389
}
390+
391+
// SelectionModeCallback implementation
392+
393+
override fun onSelectionModeChanged(active: Boolean) {
394+
selectionActionBar?.visibility = if (active) View.VISIBLE else View.GONE
395+
selectionBottomBar?.visibility = if (active) View.VISIBLE else View.GONE
396+
397+
// Hide the new UI banner when in selection mode
398+
if (active) {
399+
newUIBanner?.visibility = View.GONE
400+
} else if (settings.showNewUIBanner) {
401+
newUIBanner?.visibility = View.VISIBLE
402+
}
403+
404+
// Enable/disable back press callback
405+
backPressedCallback?.isEnabled = active
406+
407+
// Notify activity to hide/show its toolbar and FAB
408+
(activity as? MainActivityModern)?.onSelectionModeChanged(active)
409+
}
410+
411+
override fun onSelectionCountChanged(selected: Int, visible: Int, hiddenSelected: Int) {
412+
val text = if (hiddenSelected > 0) {
413+
getString(R.string.selection_count_with_hidden, selected, hiddenSelected)
414+
} else {
415+
resources.getQuantityString(R.plurals.selection_count, selected, selected)
416+
}
417+
selectionCountText?.text = text
418+
}
419+
420+
/** Check if fragment is currently in selection mode */
421+
fun isInSelectionMode(): Boolean = adapter.selectionMode
422+
423+
/** Exit selection mode if active */
424+
fun exitSelectionMode() {
425+
if (adapter.selectionMode) {
426+
adapter.exitSelectionMode()
427+
}
428+
}
297429

298430
companion object {
299431
private const val LOG_TAG = "ActiveEventsFragment"
300432

433+
/** Intent extra for passing selected event keys to SnoozeAllActivity */
434+
const val INTENT_SELECTED_EVENT_KEYS = "selected_event_keys"
435+
const val INTENT_TOTAL_FILTERED_COUNT = "total_filtered_count"
436+
301437
/** Provider for EventsStorage - enables DI for testing */
302438
var eventsStorageProvider: ((Context) -> EventsStorageInterface)? = null
303439

0 commit comments

Comments
 (0)