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