Skip to content

Commit 5378919

Browse files
committed
Add customization for monthly reminder repetition
1 parent baf9c7a commit 5378919

File tree

9 files changed

+461
-26
lines changed

9 files changed

+461
-26
lines changed

app/src/main/java/com/philkes/notallyx/data/model/Converters.kt

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.philkes.notallyx.data.model
22

33
import androidx.room.TypeConverter
4+
import java.util.Calendar
45
import java.util.Date
56
import org.json.JSONArray
67
import org.json.JSONException
@@ -174,18 +175,32 @@ object Converters {
174175
val jsonObject = JSONObject()
175176
jsonObject.put("value", repetition.value)
176177
jsonObject.put("unit", repetition.unit.name) // Store the TimeUnit as a string
178+
repetition.occurrence?.let { jsonObject.put("occurrence", it) }
179+
repetition.dayOfWeek?.let { jsonObject.put("dayOfWeek", it) }
177180
return jsonObject
178181
}
179182

180183
@TypeConverter
181184
fun jsonToRepetition(json: String): Repetition {
182185
val jsonObject = JSONObject(json)
183-
val value = jsonObject.getInt("value")
184-
val unit =
185-
RepetitionTimeUnit.valueOf(
186-
jsonObject.getString("unit")
187-
) // Convert string back to TimeUnit
188-
return Repetition(value, unit)
186+
val value = jsonObject.getInt("value").coerceAtLeast(1)
187+
val unit = RepetitionTimeUnit.valueOf(jsonObject.getString("unit"))
188+
val (occurrence, dayOfWeek) = getSafeRepetitionCustomization(jsonObject)
189+
return Repetition(value, unit, occurrence, dayOfWeek)
190+
}
191+
192+
private fun getSafeRepetitionCustomization(jsonObject: JSONObject): Pair<Int?, Int?> {
193+
if (jsonObject.has("occurrence") && jsonObject.has("dayOfWeek")) {
194+
val occurrence = jsonObject.getInt("occurrence")
195+
val dayOfWeek = jsonObject.getInt("dayOfWeek")
196+
if (
197+
occurrence in setOf(-1, 1, 2, 3, 4) &&
198+
dayOfWeek in Calendar.SUNDAY..Calendar.SATURDAY
199+
) {
200+
return Pair(occurrence, dayOfWeek)
201+
}
202+
}
203+
return Pair(null, null)
189204
}
190205

191206
private fun getSafeLocalName(jsonObject: JSONObject): String {

app/src/main/java/com/philkes/notallyx/data/model/ModelExtensions.kt

Lines changed: 114 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ import com.philkes.notallyx.presentation.applySpans
1212
import com.philkes.notallyx.utils.decodeToBitmap
1313
import java.io.File
1414
import java.text.DateFormat
15+
import java.text.SimpleDateFormat
1516
import java.util.Calendar
1617
import java.util.Date
18+
import java.util.Locale
1719
import org.json.JSONArray
1820
import org.json.JSONException
1921
import org.json.JSONObject
@@ -321,14 +323,55 @@ fun BaseNote.attachmentsDifferFrom(other: BaseNote): Boolean {
321323
other.audios.any { audio -> audios.none { it.name == audio.name } }
322324
}
323325

324-
fun Repetition.toText(context: Context): String =
325-
when {
326+
fun Reminder.toRepetitionText(context: Context): String {
327+
val rep = repetition ?: return context.getString(R.string.reminder_no_repetition)
328+
if (rep.unit == RepetitionTimeUnit.MONTHS && rep.occurrence == null && rep.value == 1) {
329+
val calendar = dateTime.toCalendar()
330+
val dayOfMonth = calendar.get(Calendar.DAY_OF_MONTH)
331+
val isLastDayOfMonth = dayOfMonth == calendar.getActualMaximum(Calendar.DAY_OF_MONTH)
332+
333+
val prefix =
334+
if (rep.value == 1) ""
335+
else
336+
"${context.getString(R.string.every)} ${rep.value} ${context.getString(R.string.months)}"
337+
val postfix =
338+
if (isLastDayOfMonth)
339+
context.getString(R.string.of_the_month_last, context.getString(R.string.day))
340+
else context.getString(R.string.of_the_month, "$dayOfMonth.")
341+
return "$prefix $postfix"
342+
}
343+
return rep.toText(context)
344+
}
345+
346+
fun Repetition.toText(context: Context): String {
347+
if (unit == RepetitionTimeUnit.MONTHS && occurrence != null && dayOfWeek != null) {
348+
val dayOfWeekStr =
349+
SimpleDateFormat("EEEE", Locale.getDefault())
350+
.apply {
351+
val cal = Calendar.getInstance()
352+
cal.set(Calendar.DAY_OF_WEEK, dayOfWeek!!)
353+
}
354+
.format(
355+
Calendar.getInstance().apply { set(Calendar.DAY_OF_WEEK, dayOfWeek!!) }.time
356+
)
357+
val monthlyOptionText =
358+
when (occurrence) {
359+
-1 -> context.getString(R.string.of_the_month_last, dayOfWeekStr)
360+
else -> "$occurrence. ${context.getString(R.string.of_the_month, dayOfWeekStr)}"
361+
}
362+
val prefix =
363+
if (value == 1) ""
364+
else "${context.getString(R.string.every)} $value ${context.getString(R.string.months)}"
365+
return "$prefix $monthlyOptionText"
366+
}
367+
return when {
326368
value == 1 && unit == RepetitionTimeUnit.DAYS -> context.getString(R.string.daily)
327369
value == 1 && unit == RepetitionTimeUnit.WEEKS -> context.getString(R.string.weekly)
328370
value == 1 && unit == RepetitionTimeUnit.MONTHS -> context.getString(R.string.monthly)
329371
value == 1 && unit == RepetitionTimeUnit.YEARS -> context.getString(R.string.yearly)
330372
else -> "${context.getString(R.string.every)} $value ${unit.toText(context)}"
331373
}
374+
}
332375

333376
private fun RepetitionTimeUnit.toText(context: Context): String {
334377
val resId =
@@ -388,19 +431,79 @@ fun Reminder.nextNotification(from: Date = Date()): Date? {
388431
if (repetition == null) {
389432
return null
390433
}
391-
val calendar = dateTime.toCalendar()
392-
val field = repetition!!.unit.toCalendarField()
393-
val value = repetition!!.value
394434

395-
// If from is exactly at dateTime, we want the next one
396-
while (true) {
397-
calendar.add(field, value)
398-
if (calendar.time.after(from)) {
399-
break
435+
val rep = repetition!!
436+
if (rep.unit == RepetitionTimeUnit.MONTHS && rep.occurrence != null && rep.dayOfWeek != null) {
437+
val next = dateTime.toCalendar()
438+
val fromCal = from.toCalendar()
439+
while (true) {
440+
val targetDate =
441+
findOccurrenceInMonth(
442+
next.get(Calendar.YEAR),
443+
next.get(Calendar.MONTH),
444+
rep.occurrence!!,
445+
rep.dayOfWeek!!,
446+
dateTime.toCalendar(),
447+
)
448+
if (targetDate.after(fromCal)) {
449+
return targetDate.time
450+
}
451+
next.add(Calendar.MONTH, rep.value)
400452
}
401453
}
402454

403-
return calendar.time
455+
val timeDifferenceMillis: Long = from.time - dateTime.time
456+
val intervalsPassed = timeDifferenceMillis / rep.toMillis()
457+
val unitsUntilNext = ((rep.value) * (intervalsPassed + 1)).toInt()
458+
val reminderStart = dateTime.toCalendar()
459+
reminderStart.add(rep.unit.toCalendarField(), unitsUntilNext)
460+
return reminderStart.time
461+
}
462+
463+
private fun findOccurrenceInMonth(
464+
year: Int,
465+
month: Int,
466+
occurrence: Int,
467+
dayOfWeek: Int,
468+
originalTime: Calendar,
469+
): Calendar {
470+
val cal =
471+
Calendar.getInstance().apply {
472+
set(Calendar.YEAR, year)
473+
set(Calendar.MONTH, month)
474+
set(Calendar.DAY_OF_MONTH, 1)
475+
set(Calendar.HOUR_OF_DAY, originalTime.get(Calendar.HOUR_OF_DAY))
476+
set(Calendar.MINUTE, originalTime.get(Calendar.MINUTE))
477+
set(Calendar.SECOND, originalTime.get(Calendar.SECOND))
478+
set(Calendar.MILLISECOND, originalTime.get(Calendar.MILLISECOND))
479+
}
480+
481+
if (occurrence > 0) {
482+
// Find first dayOfWeek
483+
while (cal.get(Calendar.DAY_OF_WEEK) != dayOfWeek) {
484+
cal.add(Calendar.DAY_OF_MONTH, 1)
485+
}
486+
// Advance to the Nth occurrence
487+
cal.add(Calendar.DAY_OF_MONTH, 7 * (occurrence - 1))
488+
489+
// If we advanced into the next month, it means there are fewer than 'occurrence' of that
490+
// day in this month.
491+
// The requirement says "if not, it'll behave like the previous option, so 4th day" for the
492+
// last day,
493+
// but for 1st-4th it should probably stay in the month if it exists.
494+
// Actually, for 1st-4th, they ALWAYS exist in every month. Only 5th might not exist.
495+
if (cal.get(Calendar.MONTH) != month) {
496+
// Fallback to 4th if 5th doesn't exist (though only 1-4 and -1 are currently planned)
497+
return findOccurrenceInMonth(year, month, occurrence - 1, dayOfWeek, originalTime)
498+
}
499+
} else if (occurrence == -1) {
500+
// Last occurrence
501+
cal.set(Calendar.DAY_OF_MONTH, cal.getActualMaximum(Calendar.DAY_OF_MONTH))
502+
while (cal.get(Calendar.DAY_OF_WEEK) != dayOfWeek) {
503+
cal.add(Calendar.DAY_OF_MONTH, -1)
504+
}
505+
}
506+
return cal
404507
}
405508

406509
fun Reminder.nextRepetition(from: Date = Date()): Date? {

app/src/main/java/com/philkes/notallyx/data/model/Reminder.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@ data class Reminder(
1212
var isNotificationVisible: Boolean = false,
1313
) : Parcelable
1414

15-
@Parcelize data class Repetition(var value: Int, var unit: RepetitionTimeUnit) : Parcelable
15+
@Parcelize
16+
data class Repetition(
17+
var value: Int,
18+
var unit: RepetitionTimeUnit,
19+
var occurrence: Int? = null,
20+
var dayOfWeek: Int? = null,
21+
) : Parcelable
1622

1723
enum class RepetitionTimeUnit {
1824
MINUTES,

app/src/main/java/com/philkes/notallyx/presentation/activity/note/reminders/RemindersActivity.kt

Lines changed: 105 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ import com.philkes.notallyx.presentation.view.main.reminder.ReminderListener
4141
import com.philkes.notallyx.presentation.viewmodel.NotallyModel
4242
import com.philkes.notallyx.utils.canScheduleAlarms
4343
import com.philkes.notallyx.utils.now
44+
import java.text.SimpleDateFormat
4445
import java.util.Calendar
46+
import java.util.Locale
4547
import kotlinx.coroutines.launch
4648

4749
class RemindersActivity : LockedActivity<ActivityRemindersBinding>(), ReminderListener {
@@ -238,9 +240,19 @@ class RemindersActivity : LockedActivity<ActivityRemindersBinding>(), ReminderLi
238240
value == 1 && unit == RepetitionTimeUnit.WEEKS ->
239241
Weekly.isChecked = true
240242

241-
value == 1 && unit == RepetitionTimeUnit.MONTHS ->
243+
value == 1 && unit == RepetitionTimeUnit.MONTHS && occurrence == null ->
242244
Monthly.isChecked = true
243245

246+
value == 1 &&
247+
unit == RepetitionTimeUnit.MONTHS &&
248+
occurrence != null -> {
249+
MonthlyAdvanced.apply {
250+
visibility = View.VISIBLE
251+
text = toText(this@RemindersActivity)
252+
isChecked = true
253+
}
254+
}
255+
244256
value == 1 && unit == RepetitionTimeUnit.YEARS ->
245257
Yearly.isChecked = true
246258

@@ -266,6 +278,7 @@ class RemindersActivity : LockedActivity<ActivityRemindersBinding>(), ReminderLi
266278
R.id.Weekly -> Repetition(1, RepetitionTimeUnit.WEEKS)
267279
R.id.Monthly -> Repetition(1, RepetitionTimeUnit.MONTHS)
268280
R.id.Yearly -> Repetition(1, RepetitionTimeUnit.YEARS)
281+
R.id.MonthlyAdvanced -> reminder?.repetition?.copy()
269282
R.id.Custom -> reminder?.repetition?.copy()
270283
else -> null
271284
}
@@ -284,11 +297,88 @@ class RemindersActivity : LockedActivity<ActivityRemindersBinding>(), ReminderLi
284297
None.setOnCheckedEnableButton(positiveButton)
285298
Daily.setOnCheckedEnableButton(positiveButton)
286299
Weekly.setOnCheckedEnableButton(positiveButton)
287-
Monthly.setOnCheckedEnableButton(positiveButton)
300+
Monthly.setOnClickListener {
301+
dialog.dismiss()
302+
showMonthlyAdvancedRepetitionDialog(reminder, calendar, onRepetitionSelected)
303+
}
304+
MonthlyAdvanced.setOnClickListener {
305+
dialog.dismiss()
306+
showMonthlyAdvancedRepetitionDialog(reminder, calendar, onRepetitionSelected)
307+
}
288308
Yearly.setOnCheckedEnableButton(positiveButton)
289309
}
290310
}
291311

312+
private fun showMonthlyAdvancedRepetitionDialog(
313+
reminder: Reminder? = null,
314+
calendar: Calendar,
315+
onRepetitionSelected: (Repetition?) -> Unit,
316+
) {
317+
val dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK)
318+
val dayOfWeekStr = SimpleDateFormat("EEEE", Locale.getDefault()).format(calendar.time)
319+
val dayOfMonth = calendar.get(Calendar.DAY_OF_MONTH)
320+
val isLastDayOfMonth = dayOfMonth == calendar.getActualMaximum(Calendar.DAY_OF_MONTH)
321+
322+
val monthlyOptionText =
323+
if (isLastDayOfMonth) {
324+
getString(R.string.of_the_month_last, getString(R.string.day))
325+
} else {
326+
"$dayOfMonth. ${getString(R.string.of_the_month, getString(R.string.day))}"
327+
}
328+
329+
val options =
330+
arrayOf(
331+
monthlyOptionText,
332+
"1. ${getString(R.string.of_the_month, dayOfWeekStr)}",
333+
"2. ${getString(R.string.of_the_month, dayOfWeekStr)}",
334+
"3. ${getString(R.string.of_the_month, dayOfWeekStr)}",
335+
"4. ${getString(R.string.of_the_month, dayOfWeekStr)}",
336+
getString(R.string.of_the_month_last, dayOfWeekStr),
337+
)
338+
339+
var selectedIndex = 0
340+
reminder?.repetition?.let {
341+
if (it.unit == RepetitionTimeUnit.MONTHS && it.value == 1) {
342+
selectedIndex =
343+
when (it.occurrence) {
344+
null -> 0
345+
1 -> 1
346+
2 -> 2
347+
3 -> 3
348+
4 -> 4
349+
-1 -> 5
350+
else -> 0
351+
}
352+
}
353+
}
354+
355+
MaterialAlertDialogBuilder(this)
356+
.setTitle(R.string.monthly)
357+
.setSingleChoiceItems(options, selectedIndex) { dialog, which ->
358+
val repetition =
359+
when (which) {
360+
0 -> Repetition(1, RepetitionTimeUnit.MONTHS)
361+
1 -> Repetition(1, RepetitionTimeUnit.MONTHS, 1, dayOfWeek)
362+
2 -> Repetition(1, RepetitionTimeUnit.MONTHS, 2, dayOfWeek)
363+
3 -> Repetition(1, RepetitionTimeUnit.MONTHS, 3, dayOfWeek)
364+
4 -> Repetition(1, RepetitionTimeUnit.MONTHS, 4, dayOfWeek)
365+
5 -> Repetition(1, RepetitionTimeUnit.MONTHS, -1, dayOfWeek)
366+
else -> Repetition(1, RepetitionTimeUnit.MONTHS)
367+
}
368+
onRepetitionSelected(repetition)
369+
dialog.dismiss()
370+
}
371+
.setNegativeButton(R.string.back) { dialog, _ ->
372+
dialog.dismiss()
373+
showRepetitionDialog(
374+
reminder,
375+
calendar,
376+
onRepetitionSelected = onRepetitionSelected,
377+
)
378+
}
379+
.show()
380+
}
381+
292382
private fun showCustomRepetitionDialog(
293383
reminder: Reminder? = null,
294384
calendar: Calendar,
@@ -325,7 +415,19 @@ class RemindersActivity : LockedActivity<ActivityRemindersBinding>(), ReminderLi
325415
R.id.Years -> RepetitionTimeUnit.YEARS
326416
else -> null
327417
}
328-
onRepetitionSelected(selectedTimeUnit?.let { Repetition(value, it) })
418+
onRepetitionSelected(
419+
selectedTimeUnit?.let {
420+
if (it == RepetitionTimeUnit.MONTHS && value == 1) {
421+
showMonthlyAdvancedRepetitionDialog(
422+
reminder,
423+
calendar,
424+
onRepetitionSelected,
425+
)
426+
return@setPositiveButton
427+
}
428+
Repetition(value, it)
429+
}
430+
)
329431
}
330432
.setBackgroundInsetBottom(0)
331433
.setBackgroundInsetTop(0)

app/src/main/java/com/philkes/notallyx/presentation/view/main/reminder/ReminderVH.kt

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
package com.philkes.notallyx.presentation.view.main.reminder
22

33
import androidx.recyclerview.widget.RecyclerView
4-
import com.philkes.notallyx.R
54
import com.philkes.notallyx.data.model.Reminder
6-
import com.philkes.notallyx.data.model.toText
5+
import com.philkes.notallyx.data.model.toRepetitionText
76
import com.philkes.notallyx.databinding.RecyclerReminderBinding
87
import com.philkes.notallyx.presentation.format
98

@@ -15,9 +14,7 @@ class ReminderVH(
1514
fun bind(value: Reminder) {
1615
binding.apply {
1716
DateTime.text = value.dateTime.format()
18-
Repetition.text =
19-
value.repetition?.toText(itemView.context)
20-
?: itemView.context.getText(R.string.reminder_no_repetition)
17+
Repetition.text = value.toRepetitionText(itemView.context)
2118
EditButton.setOnClickListener { listener.edit(value) }
2219
DeleteButton.setOnClickListener { listener.delete(value) }
2320
}

0 commit comments

Comments
 (0)