diff --git a/CHANGELOG.md b/CHANGELOG.md index d603b7cc3..96ed8b4ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [1.6.0] - 2026-01-06 +### Changed +- Added specific date alarm functionality ([#42]) + ## [1.5.0] - 2025-12-16 ### Changed @@ -93,6 +97,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release. [#11]: https://github.com/FossifyOrg/Clock/issues/11 +[#42]: https://github.com/FossifyOrg/Clock/issues/42 [#107]: https://github.com/FossifyOrg/Clock/issues/107 [#144]: https://github.com/FossifyOrg/Clock/issues/144 [#158]: https://github.com/FossifyOrg/Clock/issues/158 diff --git a/app/src/main/kotlin/org/fossify/clock/adapters/AlarmsAdapter.kt b/app/src/main/kotlin/org/fossify/clock/adapters/AlarmsAdapter.kt index c29d72fe6..5adb30551 100644 --- a/app/src/main/kotlin/org/fossify/clock/adapters/AlarmsAdapter.kt +++ b/app/src/main/kotlin/org/fossify/clock/adapters/AlarmsAdapter.kt @@ -209,8 +209,7 @@ class AlarmsAdapter( return when { !isEnabled -> resources.getString(R.string.not_scheduled) - alarm.isToday() -> resources.getString(org.fossify.commons.R.string.today) - else -> resources.getString(org.fossify.commons.R.string.tomorrow) + else -> alarm.getDateLabel(activity) } } diff --git a/app/src/main/kotlin/org/fossify/clock/dialogs/EditAlarmDialog.kt b/app/src/main/kotlin/org/fossify/clock/dialogs/EditAlarmDialog.kt index 06a992637..ac3f9be78 100644 --- a/app/src/main/kotlin/org/fossify/clock/dialogs/EditAlarmDialog.kt +++ b/app/src/main/kotlin/org/fossify/clock/dialogs/EditAlarmDialog.kt @@ -1,5 +1,6 @@ package org.fossify.clock.dialogs +import android.app.DatePickerDialog import android.app.TimePickerDialog import android.graphics.drawable.Drawable import android.media.AudioManager @@ -27,6 +28,7 @@ import org.fossify.commons.dialogs.SelectAlarmSoundDialog import org.fossify.commons.extensions.addBit import org.fossify.commons.extensions.applyColorFilter import org.fossify.commons.extensions.beVisibleIf +import org.fossify.commons.extensions.beGone import org.fossify.commons.extensions.getAlertDialogBuilder import org.fossify.commons.extensions.getDefaultAlarmSound import org.fossify.commons.extensions.getProperBackgroundColor @@ -38,6 +40,7 @@ import org.fossify.commons.extensions.setupDialogStuff import org.fossify.commons.extensions.toast import org.fossify.commons.extensions.value import org.fossify.commons.models.AlarmSound +import java.util.Calendar class EditAlarmDialog( val activity: SimpleActivity, @@ -51,6 +54,7 @@ class EditAlarmDialog( init { restoreLastAlarm() updateAlarmTime() + updateDateSelectorUI() binding.apply { editAlarmTime.setOnClickListener { @@ -119,11 +123,21 @@ class EditAlarmDialog( editAlarmLabelImage.applyColorFilter(textColor) editAlarm.setText(alarm.label) + // Date selector setup + editAlarmCalendarIcon.applyColorFilter(textColor) + editAlarmDateClear.applyColorFilter(textColor) + editAlarmDateSelector.setOnClickListener { + showDatePicker() + } + editAlarmDateClear.setOnClickListener { + clearSpecificDate() + } val dayLetters = activity.resources.getStringArray(org.fossify.commons.R.array.week_day_letters) .toList() as ArrayList val dayIndexes = activity.rotateWeekdays(arrayListOf(0, 1, 2, 3, 4, 5, 6)) + dayIndexes.forEach { val bitmask = 1 shl it @@ -154,6 +168,7 @@ class EditAlarmDialog( editAlarmDaysHolder.addView(day) } + updateWeekdaysVisibility() } activity.getAlertDialogBuilder() @@ -239,7 +254,7 @@ class EditAlarmDialog( } private fun checkDaylessAlarm() { - if (!alarm.isRecurring()) { + if (!alarm.isRecurring() && !alarm.hasSpecificDate()) { val textId = if (alarm.timeInMinutes > getCurrentDayMinutes()) { org.fossify.commons.R.string.today } else { @@ -247,6 +262,8 @@ class EditAlarmDialog( } binding.editAlarmDaylessLabel.text = "(${activity.getString(textId)})" + } else if (alarm.hasSpecificDate()) { + binding.editAlarmDaylessLabel.text = "(${alarm.getDateLabel(activity)})" } binding.editAlarmDaylessLabel.beVisibleIf(!alarm.isRecurring()) } @@ -268,4 +285,60 @@ class EditAlarmDialog( alarm.soundUri = alarmSound.uri binding.editAlarmSound.text = alarmSound.title } + + private fun showDatePicker() { + val calendar = Calendar.getInstance() + alarm.specificDate?.let { + calendar.timeInMillis = it + } + + DatePickerDialog( + activity, + { _, year, month, dayOfMonth -> + val selectedDate = Calendar.getInstance().apply { + set(Calendar.YEAR, year) + set(Calendar.MONTH, month) + set(Calendar.DAY_OF_MONTH, dayOfMonth) + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + } + alarm.specificDate = selectedDate.timeInMillis + alarm.days = 0 // Clear recurring days when setting specific date + updateDateSelectorUI() + updateWeekdaysVisibility() + checkDaylessAlarm() + }, + calendar.get(Calendar.YEAR), + calendar.get(Calendar.MONTH), + calendar.get(Calendar.DAY_OF_MONTH) + ).apply { + datePicker.minDate = System.currentTimeMillis() - 1000 // Allow today + show() + } + } + + private fun clearSpecificDate() { + alarm.specificDate = null + updateDateSelectorUI() + updateWeekdaysVisibility() + checkDaylessAlarm() + } + + private fun updateDateSelectorUI() { + binding.apply { + if (alarm.hasSpecificDate()) { + editAlarmDateLabel.text = alarm.getDateLabel(activity) + editAlarmDateClear.beVisibleIf(true) + } else { + editAlarmDateLabel.text = activity.getString(R.string.select_specific_date) + editAlarmDateClear.beGone() + } + } + } + + private fun updateWeekdaysVisibility() { + binding.editAlarmDaysHolder.beVisibleIf(!alarm.hasSpecificDate()) + } } diff --git a/app/src/main/kotlin/org/fossify/clock/helpers/Constants.kt b/app/src/main/kotlin/org/fossify/clock/helpers/Constants.kt index 2d812c03d..16d041e8a 100644 --- a/app/src/main/kotlin/org/fossify/clock/helpers/Constants.kt +++ b/app/src/main/kotlin/org/fossify/clock/helpers/Constants.kt @@ -274,6 +274,15 @@ fun getAllTimeZones() = arrayListOf( ) fun getTimeOfNextAlarm(alarm: Alarm): Calendar? { + if (alarm.specificDate != null) { + return Calendar.getInstance().apply { + timeInMillis = alarm.specificDate!! + set(Calendar.HOUR_OF_DAY, alarm.timeInMinutes / 60) + set(Calendar.MINUTE, alarm.timeInMinutes % 60) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + } + } return getTimeOfNextAlarm(alarm.timeInMinutes, alarm.days) } @@ -304,7 +313,7 @@ fun getTimeOfNextAlarm(alarmTimeInMinutes: Int, days: Int): Calendar? { } fun updateNonRecurringAlarmDay(alarm: Alarm) { - if (alarm.isRecurring()) return + if (alarm.isRecurring() || alarm.hasSpecificDate()) return alarm.days = if (alarm.timeInMinutes > getCurrentDayMinutes()) { TODAY_BIT } else { diff --git a/app/src/main/kotlin/org/fossify/clock/helpers/DBHelper.kt b/app/src/main/kotlin/org/fossify/clock/helpers/DBHelper.kt index b40975992..5c1825335 100644 --- a/app/src/main/kotlin/org/fossify/clock/helpers/DBHelper.kt +++ b/app/src/main/kotlin/org/fossify/clock/helpers/DBHelper.kt @@ -20,6 +20,7 @@ import org.fossify.commons.helpers.THURSDAY_BIT import org.fossify.commons.helpers.TUESDAY_BIT import org.fossify.commons.helpers.WEDNESDAY_BIT +@Suppress("VariableNaming") class DBHelper private constructor( val context: Context, ) : SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) { @@ -34,11 +35,14 @@ class DBHelper private constructor( private val COL_SOUND_URI = "sound_uri" private val COL_LABEL = "label" private val COL_ONE_SHOT = "one_shot" + private val COL_SPECIFIC_DATE = "specific_date" private val mDb = writableDatabase companion object { - private const val DB_VERSION = 2 + private const val DB_VERSION = 3 + private const val DB_VERSION_1 = 1 + private const val DB_VERSION_3 = 3 const val DB_NAME = "alarms.db" @SuppressLint("StaticFieldLeak") @@ -55,16 +59,21 @@ class DBHelper private constructor( override fun onCreate(db: SQLiteDatabase) { db.execSQL( - "CREATE TABLE IF NOT EXISTS $ALARMS_TABLE_NAME ($COL_ID INTEGER PRIMARY KEY AUTOINCREMENT, $COL_TIME_IN_MINUTES INTEGER, $COL_DAYS INTEGER, " + - "$COL_IS_ENABLED INTEGER, $COL_VIBRATE INTEGER, $COL_SOUND_TITLE TEXT, $COL_SOUND_URI TEXT, $COL_LABEL TEXT, $COL_ONE_SHOT INTEGER)" + "CREATE TABLE IF NOT EXISTS $ALARMS_TABLE_NAME ($COL_ID INTEGER PRIMARY KEY AUTOINCREMENT, " + + "$COL_TIME_IN_MINUTES INTEGER, $COL_DAYS INTEGER, $COL_IS_ENABLED INTEGER, " + + "$COL_VIBRATE INTEGER, $COL_SOUND_TITLE TEXT, $COL_SOUND_URI TEXT, $COL_LABEL TEXT, " + + "$COL_ONE_SHOT INTEGER, $COL_SPECIFIC_DATE INTEGER)" ) insertInitialAlarms(db) } override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { - if (oldVersion == 1 && newVersion > oldVersion) { + if (oldVersion == DB_VERSION_1 && newVersion > oldVersion) { db.execSQL("ALTER TABLE $ALARMS_TABLE_NAME ADD COLUMN $COL_ONE_SHOT INTEGER NOT NULL DEFAULT 0") } + if (oldVersion < DB_VERSION_3 && newVersion >= DB_VERSION_3) { + db.execSQL("ALTER TABLE $ALARMS_TABLE_NAME ADD COLUMN $COL_SPECIFIC_DATE INTEGER") + } } private fun insertInitialAlarms(db: SQLiteDatabase) { @@ -121,6 +130,7 @@ class DBHelper private constructor( put(COL_SOUND_URI, alarm.soundUri) put(COL_LABEL, alarm.label) put(COL_ONE_SHOT, alarm.oneShot) + put(COL_SPECIFIC_DATE, alarm.specificDate) } } @@ -137,7 +147,8 @@ class DBHelper private constructor( COL_SOUND_TITLE, COL_SOUND_URI, COL_LABEL, - COL_ONE_SHOT + COL_ONE_SHOT, + COL_SPECIFIC_DATE ) var cursor: Cursor? = null try { @@ -154,6 +165,12 @@ class DBHelper private constructor( val soundUri = cursor.getStringValue(COL_SOUND_URI) val label = cursor.getStringValue(COL_LABEL) val oneShot = cursor.getIntValue(COL_ONE_SHOT) == 1 + val specificDateIndex = cursor.getColumnIndex(COL_SPECIFIC_DATE) + val specificDate = if (specificDateIndex != -1 && !cursor.isNull(specificDateIndex)) { + cursor.getLong(specificDateIndex) + } else { + null + } val alarm = Alarm( id = id, @@ -164,7 +181,8 @@ class DBHelper private constructor( soundTitle = soundTitle, soundUri = soundUri, label = label, - oneShot = oneShot + oneShot = oneShot, + specificDate = specificDate ) alarms.add(alarm) } catch (e: Exception) { diff --git a/app/src/main/kotlin/org/fossify/clock/models/Alarm.kt b/app/src/main/kotlin/org/fossify/clock/models/Alarm.kt index 263ca1fac..5f6a355a4 100644 --- a/app/src/main/kotlin/org/fossify/clock/models/Alarm.kt +++ b/app/src/main/kotlin/org/fossify/clock/models/Alarm.kt @@ -1,8 +1,12 @@ package org.fossify.clock.models +import android.content.Context import androidx.annotation.Keep import org.fossify.clock.helpers.TODAY_BIT import org.fossify.clock.helpers.TOMORROW_BIT +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale @Keep @kotlinx.serialization.Serializable @@ -16,12 +20,92 @@ data class Alarm( var soundUri: String, var label: String, var oneShot: Boolean = false, + var specificDate: Long? = null, // Unix timestamp in milliseconds for specific date alarms ) { - fun isRecurring() = days > 0 + companion object { + private const val MIN_DAYS_FOR_DAY_NAME = 2 + private const val MAX_DAYS_FOR_DAY_NAME = 6 + private const val MILLIS_IN_SECOND = 1000L + private const val SECONDS_IN_MINUTE = 60 + private const val MINUTES_IN_HOUR = 60 + private const val HOURS_IN_DAY = 24 + } + + fun isRecurring() = days > 0 && specificDate == null + + fun isToday(): Boolean { + if (days == TODAY_BIT) return true + return specificDate?.let { isDateToday(it) } ?: false + } + + fun isTomorrow(): Boolean { + if (days == TOMORROW_BIT) return true + return specificDate?.let { isDateTomorrow(it) } ?: false + } - fun isToday() = days == TODAY_BIT + fun hasSpecificDate() = specificDate != null - fun isTomorrow() = days == TOMORROW_BIT + fun getDateLabel(context: Context): String { + return when { + isToday() -> context.getString(org.fossify.commons.R.string.today) + isTomorrow() -> context.getString(org.fossify.commons.R.string.tomorrow) + specificDate != null -> formatSpecificDate(specificDate!!) + isRecurring() -> "Recurring" + else -> error("Invalid alarm state: days=$days, specificDate=$specificDate") + } + } + + private fun formatSpecificDate(timestamp: Long): String { + val calendar = Calendar.getInstance().apply { timeInMillis = timestamp } + val today = Calendar.getInstance() + val daysDiff = getDaysDifference(today, calendar) + + return when { + daysDiff in MIN_DAYS_FOR_DAY_NAME..MAX_DAYS_FOR_DAY_NAME -> { + // Within next 7 days - show day name + calendar.getDisplayName(Calendar.DAY_OF_WEEK, Calendar.LONG, Locale.getDefault()) ?: "" + } + else -> { + // Show formatted date + SimpleDateFormat("MMM d, yyyy", Locale.getDefault()).format(calendar.time) + } + } + } + + private fun isDateToday(timestamp: Long): Boolean { + val alarmDate = Calendar.getInstance().apply { timeInMillis = timestamp } + val today = Calendar.getInstance() + return isSameDay(alarmDate, today) + } + + private fun isDateTomorrow(timestamp: Long): Boolean { + val alarmDate = Calendar.getInstance().apply { timeInMillis = timestamp } + val tomorrow = Calendar.getInstance().apply { add(Calendar.DAY_OF_MONTH, 1) } + return isSameDay(alarmDate, tomorrow) + } + + private fun isSameDay(cal1: Calendar, cal2: Calendar): Boolean { + return cal1.get(Calendar.YEAR) == cal2.get(Calendar.YEAR) && + cal1.get(Calendar.DAY_OF_YEAR) == cal2.get(Calendar.DAY_OF_YEAR) + } + + private fun getDaysDifference(from: Calendar, to: Calendar): Int { + val fromMidnight = from.clone() as Calendar + fromMidnight.set(Calendar.HOUR_OF_DAY, 0) + fromMidnight.set(Calendar.MINUTE, 0) + fromMidnight.set(Calendar.SECOND, 0) + fromMidnight.set(Calendar.MILLISECOND, 0) + + val toMidnight = to.clone() as Calendar + toMidnight.set(Calendar.HOUR_OF_DAY, 0) + toMidnight.set(Calendar.MINUTE, 0) + toMidnight.set(Calendar.SECOND, 0) + toMidnight.set(Calendar.MILLISECOND, 0) + + val diffMillis = toMidnight.timeInMillis - fromMidnight.timeInMillis + val millisInDay = MILLIS_IN_SECOND * SECONDS_IN_MINUTE * MINUTES_IN_HOUR * HOURS_IN_DAY + return (diffMillis / millisInDay).toInt() + } } @Keep @@ -35,6 +119,7 @@ data class ObfuscatedAlarm( var g: String, var h: String, var i: Boolean = false, + var j: Long? = null ) { - fun toAlarm() = Alarm(a, b, c, d, e, f, g, h, i) + fun toAlarm() = Alarm(a, b, c, d, e, f, g, h, i, j) } diff --git a/app/src/main/res/drawable/ic_calendar_vector.xml b/app/src/main/res/drawable/ic_calendar_vector.xml new file mode 100644 index 000000000..3da91b675 --- /dev/null +++ b/app/src/main/res/drawable/ic_calendar_vector.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_edit_alarm.xml b/app/src/main/res/layout/dialog_edit_alarm.xml index 43505b40c..abac43295 100644 --- a/app/src/main/res/layout/dialog_edit_alarm.xml +++ b/app/src/main/res/layout/dialog_edit_alarm.xml @@ -33,11 +33,48 @@ android:textSize="@dimen/big_text_size" tools:text="@string/tomorrow" /> + + + + + + + + + Missed alarm Replaced by another alarm Alarm timed out + Select specific date Timers are running