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 95d9e8315..e3fcf22bf 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 @@ -33,6 +33,8 @@ object Consts { const val DAY_IN_MILLISECONDS = 24L * 3600L * 1000L const val DAY_IN_SECONDS: Long = 3600L * 24 const val DAY_IN_MINUTES: Int = 60*24 + const val WEEK_IN_SECONDS: Long = DAY_IN_SECONDS * 7 + const val WEEK_IN_MILLISECONDS: Long = DAY_IN_MILLISECONDS * 7 const val HOUR_IN_SECONDS: Long = 3600L const val HOUR_IN_MILLISECONDS: Long = 3600L * 1000L const val MINUTE_IN_SECONDS: Long = 60L diff --git a/android/app/src/main/java/com/github/quarck/calnotify/Settings.kt b/android/app/src/main/java/com/github/quarck/calnotify/Settings.kt index 5182d7784..971b32cae 100644 --- a/android/app/src/main/java/com/github/quarck/calnotify/Settings.kt +++ b/android/app/src/main/java/com/github/quarck/calnotify/Settings.kt @@ -469,13 +469,38 @@ class Settings(context: Context) : PersistentStorageBase(context), SettingsInter .coerceIn(MIN_DAY_BOUNDARY_HOUR, MAX_DAY_BOUNDARY_HOUR) set(value) = setString(UPCOMING_EVENTS_DAY_BOUNDARY_HOUR_KEY, value.coerceIn(MIN_DAY_BOUNDARY_HOUR, MAX_DAY_BOUNDARY_HOUR).toString()) - /** Fixed hours lookahead (1-48, default 8). Bounded to prevent misconfiguration. */ + /** Fixed hours lookahead (1-48, default 8). Legacy property — prefer upcomingEventsFixedLookaheadMillis. */ var upcomingEventsFixedHours: Int get() = (getString(UPCOMING_EVENTS_FIXED_HOURS_KEY, DEFAULT_UPCOMING_EVENTS_FIXED_HOURS.toString()) .toIntOrNull() ?: DEFAULT_UPCOMING_EVENTS_FIXED_HOURS) .coerceIn(MIN_FIXED_HOURS, MAX_FIXED_HOURS) set(value) = setString(UPCOMING_EVENTS_FIXED_HOURS_KEY, value.coerceIn(MIN_FIXED_HOURS, MAX_FIXED_HOURS).toString()) + /** Fixed lookahead in milliseconds. Clamped to MAX_LOOKAHEAD_MILLIS (scan window). + * Falls back to legacy upcomingEventsFixedHours if no millis value is stored. */ + var upcomingEventsFixedLookaheadMillis: Long + get() { + val raw = getLong(UPCOMING_FIXED_LOOKAHEAD_MILLIS_KEY, -1L) + if (raw > 0) return raw.coerceAtMost(MAX_LOOKAHEAD_MILLIS) + return (upcomingEventsFixedHours.toLong() * Consts.HOUR_IN_MILLISECONDS).coerceAtMost(MAX_LOOKAHEAD_MILLIS) + } + set(value) = setLong(UPCOMING_FIXED_LOOKAHEAD_MILLIS_KEY, value.coerceIn(1L, MAX_LOOKAHEAD_MILLIS)) + + /** Raw configurable upcoming time presets string (e.g., "4h, 8h, 1d, 3d, 1w") */ + val upcomingTimePresetsRaw: String + get() = getString(UPCOMING_TIME_PRESETS_KEY, DEFAULT_UPCOMING_TIME_PRESETS) + + /** Parsed upcoming time presets in milliseconds. Filters out negative and >30d values. */ + val upcomingTimePresets: LongArray + get() { + val ret = PreferenceUtils.parseSnoozePresets(upcomingTimePresetsRaw) + ?: PreferenceUtils.parseSnoozePresets(DEFAULT_UPCOMING_TIME_PRESETS) + ?: return longArrayOf() + return ret.filter { it > 0 && it <= MAX_LOOKAHEAD_MILLIS } + .take(MAX_UPCOMING_TIME_PRESETS) + .toLongArray() + } + /** Max calendars to show in calendar filter. 0 = no limit (show all). */ val calendarFilterMaxItems: Int get() = getString(CALENDAR_FILTER_MAX_ITEMS_KEY, DEFAULT_CALENDAR_FILTER_MAX_ITEMS.toString()) @@ -586,6 +611,8 @@ class Settings(context: Context) : PersistentStorageBase(context), SettingsInter private const val UPCOMING_EVENTS_MODE_KEY = "upcoming_events_mode" private const val UPCOMING_EVENTS_DAY_BOUNDARY_HOUR_KEY = "upcoming_events_day_boundary_hour" private const val UPCOMING_EVENTS_FIXED_HOURS_KEY = "upcoming_events_fixed_hours" + private const val UPCOMING_FIXED_LOOKAHEAD_MILLIS_KEY = "upcoming_fixed_lookahead_millis" + private const val UPCOMING_TIME_PRESETS_KEY = "pref_upcoming_time_presets" private const val CALENDAR_FILTER_MAX_ITEMS_KEY = "calendar_filter_max_items" private const val CALENDAR_FILTER_SHOW_SEARCH_KEY = "calendar_filter_show_search" private const val CALENDAR_FILTER_SHOW_IDS_KEY = "calendar_filter_show_ids" @@ -607,6 +634,10 @@ class Settings(context: Context) : PersistentStorageBase(context), SettingsInter internal const val MAX_DAY_BOUNDARY_HOUR = 10 // 10am (max slack for night owls) internal const val MIN_FIXED_HOURS = 1 internal const val MAX_FIXED_HOURS = 48 + internal const val DEFAULT_UPCOMING_TIME_PRESETS = "4h, 8h, 1d, 3d, 1w" + internal const val MAX_UPCOMING_TIME_PRESETS = 10 + internal const val MAX_LOOKAHEAD_DAYS = 30L + internal const val MAX_LOOKAHEAD_MILLIS = MAX_LOOKAHEAD_DAYS * Consts.DAY_IN_MILLISECONDS /** Default max calendars in filter (0 = no limit) */ internal const val DEFAULT_CALENDAR_FILTER_MAX_ITEMS = 20 } diff --git a/android/app/src/main/java/com/github/quarck/calnotify/prefs/NavigationSettingsFragmentX.kt b/android/app/src/main/java/com/github/quarck/calnotify/prefs/NavigationSettingsFragmentX.kt index a99eb96a6..6134738b4 100644 --- a/android/app/src/main/java/com/github/quarck/calnotify/prefs/NavigationSettingsFragmentX.kt +++ b/android/app/src/main/java/com/github/quarck/calnotify/prefs/NavigationSettingsFragmentX.kt @@ -27,6 +27,7 @@ import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat +import androidx.preference.PreferenceDialogFragmentCompat import com.github.quarck.calnotify.R import com.github.quarck.calnotify.Settings import com.github.quarck.calnotify.ui.MainActivityLegacy @@ -41,24 +42,34 @@ class NavigationSettingsFragmentX : PreferenceFragmentCompat() { companion object { // Delay before restarting app to let user see the "Restarting..." toast private const val RESTART_DELAY_FOR_TOAST_VISIBILITY_MS = 500L + private const val DIALOG_FRAGMENT_TAG = "NavigationSettingsFragmentX.DIALOG" } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.navigation_preferences, rootKey) - // Set up click handler for "Switch to Classic View" button findPreference("switch_to_classic_view")?.setOnPreferenceClickListener { showSwitchToClassicViewDialog() true } - // Set up click handler for "Switch to New View" button findPreference("switch_to_new_view")?.setOnPreferenceClickListener { showSwitchToNewViewDialog() true } } + override fun onDisplayPreferenceDialog(preference: Preference) { + if (preference is UpcomingTimePresetPreferenceX) { + val dialogFragment = UpcomingTimePresetPreferenceX.Dialog.newInstance(preference.key) + @Suppress("DEPRECATION") + dialogFragment.setTargetFragment(this, 0) + dialogFragment.show(parentFragmentManager, DIALOG_FRAGMENT_TAG) + } else { + super.onDisplayPreferenceDialog(preference) + } + } + private fun showSwitchToClassicViewDialog() { val ctx = context ?: return AlertDialog.Builder(ctx) diff --git a/android/app/src/main/java/com/github/quarck/calnotify/prefs/PreferenceUtils.kt b/android/app/src/main/java/com/github/quarck/calnotify/prefs/PreferenceUtils.kt index da533bf5e..c8b1a9cca 100644 --- a/android/app/src/main/java/com/github/quarck/calnotify/prefs/PreferenceUtils.kt +++ b/android/app/src/main/java/com/github/quarck/calnotify/prefs/PreferenceUtils.kt @@ -36,18 +36,23 @@ object PreferenceUtils { internal fun formatSnoozePreset(value: Long): String { val seconds = value / 1000L - if (seconds % (3600L * 24) == 0L) { - val days = seconds / (3600L * 24) + if (seconds % Consts.WEEK_IN_SECONDS == 0L) { + val weeks = seconds / Consts.WEEK_IN_SECONDS + return "${weeks}w" + } + + if (seconds % Consts.DAY_IN_SECONDS == 0L) { + val days = seconds / Consts.DAY_IN_SECONDS return "${days}d" } - if (seconds % 3600L == 0L) { - val hours = seconds / 3600L + if (seconds % Consts.HOUR_IN_SECONDS == 0L) { + val hours = seconds / Consts.HOUR_IN_SECONDS return "${hours}h" } - if (seconds % 60L == 0L) { - val minutes = seconds / 60L + if (seconds % Consts.MINUTE_IN_SECONDS == 0L) { + val minutes = seconds / Consts.MINUTE_IN_SECONDS return "${minutes}m" } @@ -70,9 +75,10 @@ object PreferenceUtils { val seconds = when (unit) { "s" -> num - "m" -> num * Consts.MINUTE_IN_SECONDS; - "h" -> num * Consts.HOUR_IN_SECONDS; - "d" -> num * Consts.DAY_IN_SECONDS; + "m" -> num * Consts.MINUTE_IN_SECONDS + "h" -> num * Consts.HOUR_IN_SECONDS + "d" -> num * Consts.DAY_IN_SECONDS + "w" -> num * Consts.WEEK_IN_SECONDS else -> throw Exception("Unknown unit ${unit}") } seconds * 1000L @@ -108,4 +114,30 @@ object PreferenceUtils { fun formatPattern(pattern: LongArray): String = pattern.map { p -> formatSnoozePreset(p) }.joinToString(", ") + + /** + * Format a millisecond duration as a human-readable label (e.g., "8 hours", "3 days", "1 week"). + * Used for display in bottom sheets and chips. + */ + fun formatPresetHumanReadable(millis: Long): String { + val seconds = millis / 1000L + + if (seconds % Consts.WEEK_IN_SECONDS == 0L) { + val weeks = seconds / Consts.WEEK_IN_SECONDS + return if (weeks == 1L) "1 week" else "$weeks weeks" + } + if (seconds % Consts.DAY_IN_SECONDS == 0L) { + val days = seconds / Consts.DAY_IN_SECONDS + return if (days == 1L) "1 day" else "$days days" + } + if (seconds % Consts.HOUR_IN_SECONDS == 0L) { + val hours = seconds / Consts.HOUR_IN_SECONDS + return if (hours == 1L) "1 hour" else "$hours hours" + } + if (seconds % Consts.MINUTE_IN_SECONDS == 0L) { + val minutes = seconds / Consts.MINUTE_IN_SECONDS + return if (minutes == 1L) "1 minute" else "$minutes minutes" + } + return "$seconds seconds" + } } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/quarck/calnotify/prefs/UpcomingTimePresetPreferenceX.kt b/android/app/src/main/java/com/github/quarck/calnotify/prefs/UpcomingTimePresetPreferenceX.kt new file mode 100644 index 000000000..cebaa11ef --- /dev/null +++ b/android/app/src/main/java/com/github/quarck/calnotify/prefs/UpcomingTimePresetPreferenceX.kt @@ -0,0 +1,142 @@ +// +// Calendar Notifications Plus +// Copyright (C) 2025 William Harris (wharris+cnplus@upscalews.com) +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software Foundation, +// Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +// + +package com.github.quarck.calnotify.prefs + +import android.app.AlertDialog +import android.content.Context +import android.os.Bundle +import android.util.AttributeSet +import android.view.View +import android.widget.EditText +import android.widget.TextView +import androidx.preference.DialogPreference +import androidx.preference.PreferenceDialogFragmentCompat +import com.github.quarck.calnotify.R +import com.github.quarck.calnotify.Settings + +class UpcomingTimePresetPreferenceX @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = androidx.preference.R.attr.dialogPreferenceStyle, + defStyleRes: Int = 0 +) : DialogPreference(context, attrs, defStyleAttr, defStyleRes) { + + var presetValue: String = Settings.DEFAULT_UPCOMING_TIME_PRESETS + private set + + init { + dialogLayoutResource = R.layout.dialog_upcoming_time_presets + positiveButtonText = context.getString(android.R.string.ok) + negativeButtonText = context.getString(android.R.string.cancel) + } + + fun persistPreset(value: String) { + presetValue = value + persistString(value) + notifyChanged() + } + + override fun onSetInitialValue(defaultValue: Any?) { + presetValue = getPersistedString((defaultValue as? String) ?: Settings.DEFAULT_UPCOMING_TIME_PRESETS) + } + + override fun onGetDefaultValue(a: android.content.res.TypedArray, index: Int): Any? { + return a.getString(index) + } + + class Dialog : PreferenceDialogFragmentCompat() { + private var edit: EditText? = null + + override fun onBindDialogView(view: View) { + super.onBindDialogView(view) + + val pref = preference as UpcomingTimePresetPreferenceX + + val label = view.findViewById(R.id.text_label_upcoming_presets) + label?.text = getString(R.string.dialog_upcoming_time_presets_label, Settings.MAX_LOOKAHEAD_DAYS.toInt(), Settings.MAX_UPCOMING_TIME_PRESETS) + + edit = view.findViewById(R.id.edit_text_upcoming_time_presets) + edit?.setText(pref.presetValue) + } + + override fun onDialogClosed(positiveResult: Boolean) { + if (positiveResult) { + val value = edit?.text?.toString() + + if (value != null) { + val presets = PreferenceUtils.parseSnoozePresets(value) + if (presets != null) { + // Filter out negative values + val validPresets = presets.filter { it > 0 } + val newValue = if (validPresets.isEmpty()) { + Settings.DEFAULT_UPCOMING_TIME_PRESETS + } else { + value.split(',') + .map { it.trim() } + .filter { it.isNotEmpty() } + .joinToString(", ") + } + + val pref = preference as UpcomingTimePresetPreferenceX + if (pref.callChangeListener(newValue)) { + pref.persistPreset(newValue) + } + + if (validPresets.size > Settings.MAX_UPCOMING_TIME_PRESETS) { + showFormattedMessage(R.string.error_too_many_upcoming_presets, Settings.MAX_UPCOMING_TIME_PRESETS) + } + } else { + showMessage(R.string.error_cannot_parse_preset) + } + } + } + } + + private fun showMessage(id: Int) { + val context = requireContext() + AlertDialog.Builder(context) + .setMessage(context.getString(id)) + .setCancelable(false) + .setPositiveButton(android.R.string.ok) { _, _ -> } + .create() + .show() + } + + private fun showFormattedMessage(id: Int, vararg args: Any) { + val context = requireContext() + AlertDialog.Builder(context) + .setMessage(context.getString(id, *args)) + .setCancelable(false) + .setPositiveButton(android.R.string.ok) { _, _ -> } + .create() + .show() + } + + companion object { + fun newInstance(key: String): Dialog { + val fragment = Dialog() + val args = Bundle(1) + args.putString(ARG_KEY, key) + fragment.arguments = args + return fragment + } + } + } +} 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 e336a49a6..553fdf7fb 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 @@ -40,6 +40,9 @@ import androidx.navigation.ui.setupWithNavController import com.github.quarck.calnotify.BuildConfig import com.github.quarck.calnotify.Consts import com.github.quarck.calnotify.R +import com.github.quarck.calnotify.Settings +import com.github.quarck.calnotify.prefs.PreferenceUtils +import com.github.quarck.calnotify.upcoming.UpcomingEventsLookahead import com.github.quarck.calnotify.app.ApplicationController import com.github.quarck.calnotify.calendar.CalendarProvider import com.github.quarck.calnotify.calendar.EventAlertRecord @@ -392,9 +395,9 @@ class MainActivityModern : MainActivityBase() { addTimeChip(TimeFilterBottomSheet.TabType.ACTIVE) } R.id.upcomingEventsFragment -> { - // Upcoming tab: Calendar, Status (Time filter deferred - needs lookahead integration) addCalendarChip() addStatusChip() + addUpcomingTimeChip() } R.id.dismissedEventsFragment -> { // Dismissed tab: Calendar, Time @@ -590,6 +593,36 @@ class MainActivityModern : MainActivityBase() { bottomSheet.show(supportFragmentManager, "CalendarFilterBottomSheet") } + // === Upcoming Time Filter Chip === + + private fun addUpcomingTimeChip() { + val materialContext = ContextThemeWrapper(this, com.google.android.material.R.style.Theme_MaterialComponents_DayNight) + val chip = Chip(materialContext).apply { + text = getUpcomingTimeChipText() + isCheckable = false + isChipIconVisible = false + isCloseIconVisible = true + closeIcon = getDrawable(R.drawable.ic_arrow_drop_down) + setOnClickListener { showUpcomingTimeFilterBottomSheet() } + setOnCloseIconClickListener { showUpcomingTimeFilterBottomSheet() } + } + chipGroup?.addView(chip) + } + + private fun getUpcomingTimeChipText(): String { + val settings = Settings(this) + return if (settings.upcomingEventsMode == UpcomingEventsLookahead.MODE_DAY_BOUNDARY) { + getString(R.string.upcoming_events_mode_day_boundary) + } else { + PreferenceUtils.formatPresetHumanReadable(settings.upcomingEventsFixedLookaheadMillis) + } + } + + private fun showUpcomingTimeFilterBottomSheet() { + val bottomSheet = UpcomingTimeFilterBottomSheet.newInstance() + bottomSheet.show(supportFragmentManager, "UpcomingTimeFilterBottomSheet") + } + /** Setup Fragment Result listeners for bottom sheets (survives config changes) */ private fun setupFilterResultListeners() { // Time filter result @@ -613,6 +646,23 @@ class MainActivityModern : MainActivityBase() { updateFilterChipsForCurrentTab() notifyCurrentFragmentFilterChanged() } + + // Upcoming time filter result — writes to Settings, not FilterState + supportFragmentManager.setFragmentResultListener( + UpcomingTimeFilterBottomSheet.REQUEST_KEY, this + ) { _, bundle -> + val mode = bundle.getString(UpcomingTimeFilterBottomSheet.RESULT_MODE) ?: return@setFragmentResultListener + val settings = Settings(this) + settings.upcomingEventsMode = mode + if (mode == UpcomingEventsLookahead.MODE_FIXED) { + val millis = bundle.getLong(UpcomingTimeFilterBottomSheet.RESULT_MILLIS, -1L) + if (millis > 0) { + settings.upcomingEventsFixedLookaheadMillis = millis + } + } + updateFilterChipsForCurrentTab() + notifyCurrentFragmentFilterChanged() + } } // === Selection Mode Coordination === diff --git a/android/app/src/main/java/com/github/quarck/calnotify/ui/UpcomingTimeFilterBottomSheet.kt b/android/app/src/main/java/com/github/quarck/calnotify/ui/UpcomingTimeFilterBottomSheet.kt new file mode 100644 index 000000000..b20d5b80f --- /dev/null +++ b/android/app/src/main/java/com/github/quarck/calnotify/ui/UpcomingTimeFilterBottomSheet.kt @@ -0,0 +1,144 @@ +// +// Calendar Notifications Plus +// Copyright (C) 2025 William Harris (wharris+cnplus@upscalews.com) +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software Foundation, +// Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +// + +package com.github.quarck.calnotify.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.RadioButton +import android.widget.RadioGroup +import androidx.core.os.bundleOf +import androidx.fragment.app.setFragmentResult +import com.github.quarck.calnotify.R +import com.github.quarck.calnotify.Settings +import com.github.quarck.calnotify.prefs.PreferenceUtils +import com.github.quarck.calnotify.upcoming.UpcomingEventsLookahead +import com.github.quarck.calnotify.utils.DateTimeUtils +import com.google.android.material.bottomsheet.BottomSheetDialogFragment + +/** + * Bottom sheet for selecting the upcoming events lookahead window. + * Unlike the regular TimeFilterBottomSheet (in-memory filter), this persists to Settings + * because it controls which events get fetched from MonitorStorage. + * + * Options: + * - Day boundary mode (with configured boundary hour) + * - Configurable fixed interval presets (e.g., 4h, 8h, 1d, 3d, 1w) + */ +class UpcomingTimeFilterBottomSheet : BottomSheetDialogFragment() { + + private val DAY_BOUNDARY_RADIO_ID = View.generateViewId() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.bottom_sheet_upcoming_time_filter, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val settings = Settings(requireContext()) + val radioGroup = view.findViewById(R.id.upcoming_time_radio_group).apply { + isSaveEnabled = false + } + val applyButton = view.findViewById