diff --git a/CHANGELOG.md b/CHANGELOG.md
index fbc4a8614..2eaa36851 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [1.7.0] - 2626-07-18
+### Added
+- Added alarm groups (see [PR #473](https://github.com/FossifyOrg/Clock/pull/473) for more details)
+
## [1.6.0] - 2026-01-30
### Added
- Added support for custom fonts
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 81bf146d1..05fc11214 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -33,6 +33,14 @@
android:roundIcon="@mipmap/ic_launcher"
android:theme="@style/AppTheme">
+
+
+ if (menuItem.itemId == R.id.delete_group) {
+ ConfirmationDialog(this, messageId = R.string.delete_group_confirmation) {
+ dbHelper.deleteGroup(groupRef)
+ EventBus.getDefault().post(AlarmEvent.Refresh)
+ finish()
+ }
+ true
+ } else {
+ false
+ }
+ }
+
+ binding.groupFab.setOnClickListener {
+ val newAlarm = createNewAlarm(DEFAULT_ALARM_MINUTES, 0)
+ newAlarm.isEnabled = true
+ newAlarm.days = getTomorrowBit()
+ newAlarm.groupRef = groupRef
+ openEditAlarm(newAlarm)
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ setupTopAppBar(binding.groupAppbar, NavigationIcon.Arrow, topBarColor = getProperBackgroundColor())
+ updateTextColors(binding.groupHolder)
+ if (!setupGroupTitle()) {
+ return
+ }
+ refreshAlarms()
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ EventBus.getDefault().unregister(this)
+ }
+
+ override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) {
+ super.onActivityResult(requestCode, resultCode, resultData)
+ if (requestCode == PICK_AUDIO_FILE_INTENT_ID && resultCode == RESULT_OK && resultData != null) {
+ currentEditAlarmDialog?.updateSelectedAlarmSound(storeNewYourAlarmSound(resultData))
+ }
+ }
+
+ private fun setupGroupTitle(): Boolean {
+ val group = dbHelper.getGroups().firstOrNull { it.ref == groupRef }
+ if (group == null) {
+ finish()
+ return false
+ }
+
+ binding.groupToolbarTitle.text = group.title
+ binding.groupToolbarTitle.setTextColor(getProperTextColor())
+ binding.groupToolbarTitle.setOnClickListener {
+ RenameGroupDialog(this, group) {
+ setupGroupTitle()
+ }
+ }
+ return true
+ }
+
+ private fun refreshAlarms() {
+ ensureBackgroundThread {
+ val alarms = dbHelper.getAlarms()
+ .filter { it.groupRef == groupRef }
+ .sortedBy { it.timeInMinutes }
+
+ runOnUiThread {
+ val items = ArrayList(alarms.map { AlarmListItem.AlarmRow(it) })
+ var currAdapter = binding.groupList.adapter as? AlarmsAdapter
+ if (currAdapter == null) {
+ currAdapter = AlarmsAdapter(
+ activity = this,
+ items = items,
+ toggleAlarmInterface = this,
+ recyclerView = binding.groupList
+ ) {
+ if (it is Alarm) {
+ openEditAlarm(it)
+ }
+ }.apply {
+ binding.groupList.adapter = this
+ }
+ } else {
+ currAdapter.apply {
+ updatePrimaryColor()
+ updateBackgroundColor(getProperBackgroundColor())
+ updateTextColor(getProperTextColor())
+ updateItems(items)
+ }
+ }
+ binding.groupPlaceholder.beVisibleIf(items.isEmpty())
+ }
+ }
+ }
+
+ private fun openEditAlarm(alarm: Alarm) {
+ currentEditAlarmDialog = EditAlarmDialog(this, alarm) {
+ alarm.id = it
+ currentEditAlarmDialog = null
+ refreshAlarms()
+ checkAlarmState(alarm)
+ }
+ }
+
+ private fun checkAlarmState(alarm: Alarm) {
+ if (alarm.isEnabled) {
+ alarmController.scheduleNextOccurrence(alarm = alarm, showToasts = true)
+ } else {
+ cancelAlarmClock(alarm)
+ }
+ }
+
+ override fun alarmToggled(id: Int, isEnabled: Boolean) {
+ handleFullScreenNotificationsPermission { granted ->
+ if (granted) {
+ if (dbHelper.updateAlarmEnabledState(id, isEnabled)) {
+ dbHelper.getAlarmWithId(id)?.let { alarm ->
+ alarm.isEnabled = isEnabled
+ checkAlarmState(alarm)
+ if (!alarm.isEnabled && alarm.oneShot) {
+ dbHelper.deleteAlarms(arrayListOf(alarm))
+ }
+ }
+ refreshAlarms()
+ } else {
+ toast(org.fossify.commons.R.string.unknown_error_occurred)
+ }
+ updateWidgets()
+ } else {
+ refreshAlarms()
+ }
+ }
+ }
+
+ @Subscribe(threadMode = ThreadMode.MAIN)
+ fun onMessageEvent(@Suppress("unused") event: AlarmEvent.Refresh) {
+ refreshAlarms()
+ }
+}
diff --git a/app/src/main/kotlin/org/fossify/clock/activities/SettingsActivity.kt b/app/src/main/kotlin/org/fossify/clock/activities/SettingsActivity.kt
index 66a10e184..a573bd043 100644
--- a/app/src/main/kotlin/org/fossify/clock/activities/SettingsActivity.kt
+++ b/app/src/main/kotlin/org/fossify/clock/activities/SettingsActivity.kt
@@ -307,12 +307,13 @@ class SettingsActivity : SimpleActivity() {
private fun exportData(outputUri: Uri) {
val alarms = dbHelper.getAlarms()
+ val groups = dbHelper.getGroups()
val timers = timerDb.getTimers()
if (alarms.isEmpty() && timers.isEmpty()) {
toast(org.fossify.commons.R.string.no_entries_for_exporting)
} else {
ExportHelper(this).exportData(
- backup = AlarmTimerBackup(alarms, timers),
+ backup = AlarmTimerBackup(alarms, groups, timers),
outputUri = outputUri,
) {
toast(
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..0f5b1a304 100644
--- a/app/src/main/kotlin/org/fossify/clock/adapters/AlarmsAdapter.kt
+++ b/app/src/main/kotlin/org/fossify/clock/adapters/AlarmsAdapter.kt
@@ -8,21 +8,28 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import org.fossify.clock.R
+import org.fossify.clock.activities.GroupActivity
import org.fossify.clock.activities.SimpleActivity
import org.fossify.clock.databinding.ItemAlarmBinding
+import org.fossify.clock.databinding.ItemAlarmGroupBinding
+import org.fossify.clock.dialogs.AddToGroupDialog
import org.fossify.clock.extensions.config
import org.fossify.clock.extensions.dbHelper
import org.fossify.clock.extensions.getFormattedTime
+import org.fossify.clock.extensions.handleFullScreenNotificationsPermission
import org.fossify.clock.helpers.updateNonRecurringAlarmDay
import org.fossify.clock.interfaces.ToggleAlarmInterface
import org.fossify.clock.models.Alarm
import org.fossify.clock.models.AlarmEvent
+import org.fossify.clock.models.AlarmListItem
+import org.fossify.clock.models.Group
import org.fossify.commons.adapters.MyRecyclerViewAdapter
import org.fossify.commons.dialogs.ConfirmationDialog
import org.fossify.commons.extensions.applyColorFilter
import org.fossify.commons.extensions.beVisibleIf
import org.fossify.commons.extensions.getSelectedDaysString
import org.fossify.commons.extensions.move
+import org.fossify.commons.extensions.moveLastItemToFront
import org.fossify.commons.helpers.EVERY_DAY_BIT
import org.fossify.commons.helpers.SORT_BY_CUSTOM
import org.fossify.commons.interfaces.ItemMoveCallback
@@ -33,12 +40,17 @@ import org.greenrobot.eventbus.EventBus
class AlarmsAdapter(
activity: SimpleActivity,
- private var alarms: ArrayList,
+ private var items: ArrayList,
private val toggleAlarmInterface: ToggleAlarmInterface,
recyclerView: MyRecyclerView,
itemClick: (Any) -> Unit,
) : MyRecyclerViewAdapter(activity, recyclerView, itemClick), ItemTouchHelperContract {
+ companion object {
+ private const val VIEW_TYPE_ALARM = 0
+ private const val VIEW_TYPE_GROUP = 1
+ }
+
private var startReorderDragListener: StartReorderDragListener
init {
@@ -63,17 +75,21 @@ class AlarmsAdapter(
}
when (id) {
+ R.id.cab_add_to_group -> addSelectedToGroup()
R.id.cab_delete -> deleteItems()
}
}
- override fun getSelectableItemCount() = alarms.size
+ override fun getSelectableItemCount() = items.count { it is AlarmListItem.AlarmRow }
- override fun getIsItemSelectable(position: Int) = true
+ override fun getIsItemSelectable(position: Int) = items.getOrNull(position) is AlarmListItem.AlarmRow
- override fun getItemSelectionKey(position: Int) = alarms.getOrNull(position)?.id
+ override fun getItemSelectionKey(position: Int) =
+ (items.getOrNull(position) as? AlarmListItem.AlarmRow)?.alarm?.id
- override fun getItemKeyPosition(key: Int) = alarms.indexOfFirst { it.id == key }
+ override fun getItemKeyPosition(key: Int): Int {
+ return items.indexOfFirst { it is AlarmListItem.AlarmRow && it.alarm.id == key }
+ }
@SuppressLint("NotifyDataSetChanged")
override fun onActionModeCreated() {
@@ -89,46 +105,78 @@ class AlarmsAdapter(
override fun onRowSelected(myViewHolder: ViewHolder?) {}
+ override fun getItemViewType(position: Int): Int {
+ return when (items[position]) {
+ is AlarmListItem.GroupRow -> VIEW_TYPE_GROUP
+ is AlarmListItem.AlarmRow -> VIEW_TYPE_ALARM
+ }
+ }
+
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
- return createViewHolder(ItemAlarmBinding.inflate(layoutInflater, parent, false).root)
+ return if (viewType == VIEW_TYPE_GROUP) {
+ createViewHolder(ItemAlarmGroupBinding.inflate(layoutInflater, parent, false).root)
+ } else {
+ createViewHolder(ItemAlarmBinding.inflate(layoutInflater, parent, false).root)
+ }
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
- val alarm = alarms[position]
- holder.bindView(
- any = alarm,
- allowSingleClick = true,
- allowLongClick = true
- ) { itemView, _ ->
- setupView(itemView, alarm, holder)
+ when (val item = items[position]) {
+ is AlarmListItem.GroupRow -> {
+ holder.bindView(
+ any = item.group,
+ allowSingleClick = true,
+ allowLongClick = false
+ ) { itemView, _ ->
+ setupGroupView(itemView, item)
+ }
+ }
+
+ is AlarmListItem.AlarmRow -> {
+ holder.bindView(
+ any = item.alarm,
+ allowSingleClick = true,
+ allowLongClick = true
+ ) { itemView, _ ->
+ setupView(itemView, item.alarm, holder)
+ }
+ }
}
bindViewHolder(holder)
}
- override fun getItemCount() = alarms.size
+ override fun getItemCount() = items.size
@SuppressLint("NotifyDataSetChanged")
- fun updateItems(newItems: ArrayList) {
- alarms = newItems
+ fun updateItems(newItems: ArrayList) {
+ items = newItems
notifyDataSetChanged()
finishActMode()
}
- private fun deleteItems() {
- val alarmsToRemove = ArrayList()
- val positions = getSelectedItemPositions()
- getSelectedItems().forEach {
- alarmsToRemove.add(it)
+ private fun addSelectedToGroup() {
+ val selectedIds = getSelectedAlarms().map { it.id }
+ AddToGroupDialog(activity as SimpleActivity) { groupId ->
+ activity.dbHelper.assignAlarmsToGroup(selectedIds, groupId)
+ finishActMode()
+ EventBus.getDefault().post(AlarmEvent.Refresh)
}
+ }
- alarms.removeAll(alarmsToRemove)
+ private fun deleteItems() {
+ val positions = getSelectedItemPositions()
+ val alarmsToRemove = getSelectedAlarms()
+ items.removeAll { it is AlarmListItem.AlarmRow && alarmsToRemove.contains(it.alarm) }
removeSelectedItems(positions)
activity.dbHelper.deleteAlarms(alarmsToRemove)
EventBus.getDefault().post(AlarmEvent.Refresh)
}
- private fun getSelectedItems(): ArrayList {
- return alarms.filter { selectedKeys.contains(it.id) } as ArrayList
+ private fun getSelectedAlarms(): ArrayList {
+ return items.filterIsInstance()
+ .map { it.alarm }
+ .filter { selectedKeys.contains(it.id) }
+ .toCollection(ArrayList())
}
@SuppressLint("ClickableViewAccessibility")
@@ -166,6 +214,38 @@ class AlarmsAdapter(
}
}
+ private fun setupGroupView(view: View, groupRow: AlarmListItem.GroupRow) {
+ ItemAlarmGroupBinding.bind(view).apply {
+ val group = groupRow.group
+ val alarms = groupRow.alarms
+
+ groupIcon.applyColorFilter(textColor)
+ groupTitle.text = group.title
+ groupTitle.setTextColor(textColor)
+
+ groupSubtitle.text = resources.getQuantityString(
+ R.plurals.group_alarm_count, alarms.size, alarms.size
+ )
+ groupSubtitle.setTextColor(textColor)
+
+ val anyEnabled = alarms.isNotEmpty() && alarms.any { it.isEnabled }
+ groupSwitch.setColors(textColor, properPrimaryColor, backgroundColor)
+ groupSwitch.isChecked = anyEnabled
+ groupSwitch.setOnClickListener {
+ toggleGroup(group.ref, groupSwitch.isChecked)
+ }
+ }
+ }
+
+ private fun toggleGroup(groupId: Int, isEnabled: Boolean) {
+ (activity as SimpleActivity).handleFullScreenNotificationsPermission { granted ->
+ if (granted) {
+ val updatedAlarms = activity.dbHelper.updateGroupEnabledState(groupId, isEnabled)
+ updatedAlarms.forEach { toggleAlarmInterface.alarmToggled(it.id, isEnabled) }
+ }
+ }
+ }
+
private fun toggleAlarm(binding: ItemAlarmBinding, alarm: Alarm) {
when {
alarm.isRecurring() -> {
@@ -202,7 +282,6 @@ class AlarmsAdapter(
return if (alarm.days == EVERY_DAY_BIT) {
activity.getString(org.fossify.commons.R.string.every_day)
} else {
- // TODO: This does not respect config.firstDayOfWeek
activity.getSelectedDaysString(alarm.days)
}
}
@@ -215,17 +294,16 @@ class AlarmsAdapter(
}
override fun onRowMoved(fromPosition: Int, toPosition: Int) {
- alarms.move(fromPosition, toPosition)
+ items.move(fromPosition, toPosition)
notifyItemMoved(fromPosition, toPosition)
- saveAlarmsCustomOrder(alarms)
+ saveAlarmsCustomOrder()
if (activity.config.alarmSort != SORT_BY_CUSTOM) {
activity.config.alarmSort = SORT_BY_CUSTOM
}
}
- private fun saveAlarmsCustomOrder(alarms: ArrayList) {
- val alarmsCustomSortingIds = alarms.map { it.id }
-
- activity.config.alarmsCustomSorting = alarmsCustomSortingIds.joinToString { it.toString() }
+ private fun saveAlarmsCustomOrder() {
+ val ids = items.filterIsInstance().map { it.alarm.id }
+ activity.config.alarmsCustomSorting = ids.joinToString { it.toString() }
}
}
diff --git a/app/src/main/kotlin/org/fossify/clock/dialogs/AddToGroupDialog.kt b/app/src/main/kotlin/org/fossify/clock/dialogs/AddToGroupDialog.kt
new file mode 100644
index 000000000..f11421031
--- /dev/null
+++ b/app/src/main/kotlin/org/fossify/clock/dialogs/AddToGroupDialog.kt
@@ -0,0 +1,63 @@
+package org.fossify.clock.dialogs
+
+import android.widget.Toast
+import org.fossify.clock.activities.SimpleActivity
+import org.fossify.clock.databinding.DialogAddToGroupBinding
+import org.fossify.clock.extensions.dbHelper
+import org.fossify.clock.models.Group
+import org.fossify.commons.extensions.getAlertDialogBuilder
+import org.fossify.commons.extensions.setupDialogStuff
+import org.fossify.commons.extensions.value
+import org.fossify.commons.views.MyCompatRadioButton
+
+class AddToGroupDialog(
+ val activity: SimpleActivity,
+ val callback: (groupId: Int) -> Unit,
+) {
+ private val binding = DialogAddToGroupBinding.inflate(activity.layoutInflater)
+ private val groups = activity.dbHelper.getGroups()
+ private val radioIdToGroupId = HashMap()
+
+ init {
+ val mutableGroups = groups.toMutableList()
+
+ if (groups.any()){
+ val noneText = activity.resources.getString(org.fossify.commons.R.string.none)
+ mutableGroups.add(0, Group(0, 0, noneText))
+ }
+
+ mutableGroups.forEach { group ->
+ val radioButton = MyCompatRadioButton(activity).apply {
+ id = android.view.View.generateViewId()
+ text = group.title
+ }
+ radioIdToGroupId[radioButton.id] = group.ref
+ binding.addToGroupRadioGroup.addView(radioButton)
+ }
+
+ activity.getAlertDialogBuilder()
+ .setPositiveButton(org.fossify.commons.R.string.ok) { _, _ -> dialogConfirmed() }
+ .setNegativeButton(org.fossify.commons.R.string.cancel, null)
+ .apply {
+ activity.setupDialogStuff(binding.root, this, org.fossify.clock.R.string.add_to_group)
+ }
+ }
+
+ private fun dialogConfirmed() {
+ val newGroupName = binding.addToGroupNewName.value.trim()
+ val groupNameExists = activity.dbHelper.getGroups().any { it.title == newGroupName }
+
+ val groupId = if (newGroupName.isNotEmpty() && !groupNameExists) {
+ activity.dbHelper.insertGroup(newGroupName)
+ } else if (groupNameExists) {
+ val text = activity.resources.getString(org.fossify.clock.R.string.group_name_exists)
+ Toast.makeText(activity.baseContext, text, Toast.LENGTH_SHORT).show()
+ return
+ } else {
+ val checkedRadioId = binding.addToGroupRadioGroup.checkedRadioButtonId
+ radioIdToGroupId[checkedRadioId] ?: return
+ }
+
+ callback(groupId)
+ }
+}
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..b9d63fc30 100644
--- a/app/src/main/kotlin/org/fossify/clock/dialogs/EditAlarmDialog.kt
+++ b/app/src/main/kotlin/org/fossify/clock/dialogs/EditAlarmDialog.kt
@@ -122,7 +122,6 @@ class EditAlarmDialog(
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 {
diff --git a/app/src/main/kotlin/org/fossify/clock/dialogs/RenameGroupDialog.kt b/app/src/main/kotlin/org/fossify/clock/dialogs/RenameGroupDialog.kt
new file mode 100644
index 000000000..8c236dbf3
--- /dev/null
+++ b/app/src/main/kotlin/org/fossify/clock/dialogs/RenameGroupDialog.kt
@@ -0,0 +1,45 @@
+package org.fossify.clock.dialogs
+
+import androidx.appcompat.app.AlertDialog
+import org.fossify.clock.R
+import org.fossify.clock.activities.SimpleActivity
+import org.fossify.clock.databinding.DialogRenameGroupBinding
+import org.fossify.clock.extensions.dbHelper
+import org.fossify.clock.models.Group
+import org.fossify.commons.extensions.getAlertDialogBuilder
+import org.fossify.commons.extensions.setupDialogStuff
+import org.fossify.commons.extensions.showKeyboard
+import org.fossify.commons.extensions.toast
+import org.fossify.commons.extensions.value
+
+class RenameGroupDialog(
+ val activity: SimpleActivity,
+ val group: Group,
+ val callback: () -> Unit,
+) {
+ private val binding = DialogRenameGroupBinding.inflate(activity.layoutInflater).apply {
+ renameGroupTitle.setText(group.title)
+ }
+
+ init {
+ activity.getAlertDialogBuilder()
+ .setPositiveButton(org.fossify.commons.R.string.ok, null)
+ .setNegativeButton(org.fossify.commons.R.string.cancel, null)
+ .apply {
+ activity.setupDialogStuff(binding.root, this, R.string.rename_group) { alertDialog ->
+ alertDialog.showKeyboard(binding.renameGroupTitle)
+ alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
+ val newTitle = binding.renameGroupTitle.value.trim()
+ if (newTitle.isEmpty()) {
+ activity.toast(org.fossify.commons.R.string.empty_name)
+ return@setOnClickListener
+ }
+
+ activity.dbHelper.updateGroup(Group(group.id, group.ref, newTitle))
+ callback()
+ alertDialog.dismiss()
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/org/fossify/clock/fragments/AlarmFragment.kt b/app/src/main/kotlin/org/fossify/clock/fragments/AlarmFragment.kt
index 7eb52fc9e..d9c29fc6b 100644
--- a/app/src/main/kotlin/org/fossify/clock/fragments/AlarmFragment.kt
+++ b/app/src/main/kotlin/org/fossify/clock/fragments/AlarmFragment.kt
@@ -5,6 +5,7 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
+import org.fossify.clock.activities.GroupActivity
import org.fossify.clock.activities.MainActivity
import org.fossify.clock.activities.SimpleActivity
import org.fossify.clock.adapters.AlarmsAdapter
@@ -26,6 +27,8 @@ import org.fossify.clock.helpers.getTomorrowBit
import org.fossify.clock.interfaces.ToggleAlarmInterface
import org.fossify.clock.models.Alarm
import org.fossify.clock.models.AlarmEvent
+import org.fossify.clock.models.AlarmListItem
+import org.fossify.clock.models.Group
import org.fossify.commons.extensions.beVisibleIf
import org.fossify.commons.extensions.getProperBackgroundColor
import org.fossify.commons.extensions.getProperTextColor
@@ -40,7 +43,7 @@ import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
class AlarmFragment : Fragment(), ToggleAlarmInterface {
- private var alarms = ArrayList()
+ private var alarmItems = ArrayList()
private var currentEditAlarmDialog: EditAlarmDialog? = null
private lateinit var binding: FragmentAlarmBinding
@@ -89,21 +92,23 @@ class AlarmFragment : Fragment(), ToggleAlarmInterface {
setupAlarms()
}
- private fun getSortedAlarms(callback: (alarms: ArrayList) -> Unit) {
+ private fun getSortedItems(callback: (items: ArrayList) -> Unit) {
val safeContext = context ?: return
ensureBackgroundThread {
- var newAlarms = context?.dbHelper?.getAlarms()
- if (newAlarms == null) {
- activity?.runOnUiThread {
- callback(arrayListOf())
- }
+ val allAlarms = context?.dbHelper?.getAlarms()
+ if (allAlarms == null) {
+ activity?.runOnUiThread { callback(arrayListOf()) }
return@ensureBackgroundThread
}
+ val groups = safeContext.dbHelper.getGroups()
+ val groupedAlarms = allAlarms.filter { it.groupRef != 0 }.groupBy { it.groupRef }
+ var ungroupedAlarms = allAlarms.filter { it.groupRef == 0 } as ArrayList
+
when (safeContext.config.alarmSort) {
- SORT_BY_ALARM_TIME -> newAlarms.sortBy { it.timeInMinutes }
- SORT_BY_DATE_CREATED -> newAlarms.sortBy { it.id }
- SORT_BY_DATE_AND_TIME -> newAlarms.sortWith(compareBy {
+ SORT_BY_ALARM_TIME -> ungroupedAlarms.sortBy { it.timeInMinutes }
+ SORT_BY_DATE_CREATED -> ungroupedAlarms.sortBy { it.id }
+ SORT_BY_DATE_AND_TIME -> ungroupedAlarms.sortWith(compareBy {
safeContext.firstDayOrder(it.days)
}.thenBy {
it.timeInMinutes
@@ -111,12 +116,12 @@ class AlarmFragment : Fragment(), ToggleAlarmInterface {
SORT_BY_CUSTOM -> {
val customAlarmsSortOrderString = activity?.config?.alarmsCustomSorting
- if (customAlarmsSortOrderString == "") {
- newAlarms.sortBy { it.id }
+ if (customAlarmsSortOrderString.isNullOrEmpty()) {
+ ungroupedAlarms.sortBy { it.id }
} else {
val customAlarmsSortOrder: List =
- customAlarmsSortOrderString?.split(", ")?.map { it.toInt() }!!
- val alarmsIdValueMap = newAlarms.associateBy { it.id }
+ customAlarmsSortOrderString.split(", ").map { it.toInt() }
+ val alarmsIdValueMap = ungroupedAlarms.associateBy { it.id }
val sortedAlarms: ArrayList = ArrayList()
customAlarmsSortOrder.map { id ->
@@ -125,31 +130,47 @@ class AlarmFragment : Fragment(), ToggleAlarmInterface {
}
}
- newAlarms =
- (sortedAlarms + newAlarms.filter { it !in sortedAlarms }) as ArrayList
+ ungroupedAlarms =
+ (sortedAlarms + ungroupedAlarms.filter { it !in sortedAlarms }) as ArrayList
}
}
}
+ val groupItems = groups
+ .sortedBy { it.title.lowercase() }
+ .map { group ->
+ AlarmListItem.GroupRow(
+ group = group,
+ alarms = groupedAlarms[group.ref].orEmpty().sortedBy { it.timeInMinutes }
+ )
+ }
+
+ val combined = ArrayList()
+ combined.addAll(groupItems)
+ combined.addAll(ungroupedAlarms.map { AlarmListItem.AlarmRow(it) })
+
activity?.runOnUiThread {
- callback(newAlarms)
+ callback(combined)
}
}
}
private fun setupAlarms() {
- getSortedAlarms { sortedAlarms ->
- alarms = sortedAlarms
- val safeActivity = activity as? SimpleActivity ?: return@getSortedAlarms
+ getSortedItems { items ->
+ alarmItems = items
+ val safeActivity = activity as? SimpleActivity ?: return@getSortedItems
var currAdapter = binding.alarmsList.adapter as? AlarmsAdapter
if (currAdapter == null) {
currAdapter = AlarmsAdapter(
activity = safeActivity,
- alarms = alarms,
+ items = items,
toggleAlarmInterface = this,
recyclerView = binding.alarmsList
) {
- openEditAlarm(it as Alarm)
+ when (it) {
+ is Alarm -> openEditAlarm(it)
+ is Group -> GroupActivity.start(safeActivity, it.ref)
+ }
}.apply {
binding.alarmsList.adapter = this
}
@@ -158,10 +179,10 @@ class AlarmFragment : Fragment(), ToggleAlarmInterface {
updatePrimaryColor()
updateBackgroundColor(safeActivity.getProperBackgroundColor())
updateTextColor(safeActivity.getProperTextColor())
- updateItems(alarms)
+ updateItems(items)
}
}
- binding.alarmsPlaceholder.beVisibleIf(alarms.isEmpty())
+ binding.alarmsPlaceholder.beVisibleIf(items.isEmpty())
}
}
@@ -178,8 +199,7 @@ class AlarmFragment : Fragment(), ToggleAlarmInterface {
(activity as SimpleActivity).handleFullScreenNotificationsPermission { granted ->
if (granted) {
if (requireContext().dbHelper.updateAlarmEnabledState(id, isEnabled)) {
- val alarm = alarms.firstOrNull { it.id == id }
- ?: return@handleFullScreenNotificationsPermission
+ val alarm = findAlarmById(id) ?: return@handleFullScreenNotificationsPermission
alarm.isEnabled = isEnabled
checkAlarmState(alarm)
if (!alarm.isEnabled && alarm.oneShot) {
@@ -196,6 +216,16 @@ class AlarmFragment : Fragment(), ToggleAlarmInterface {
}
}
+ private fun findAlarmById(id: Int): Alarm? {
+ alarmItems.forEach { item ->
+ when (item) {
+ is AlarmListItem.AlarmRow -> if (item.alarm.id == id) return item.alarm
+ is AlarmListItem.GroupRow -> item.alarms.firstOrNull { it.id == id }?.let { return it }
+ }
+ }
+ return null
+ }
+
private fun checkAlarmState(alarm: Alarm) {
val activity = activity as? MainActivity ?: return
if (alarm.isEnabled) {
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..bab3cd681 100644
--- a/app/src/main/kotlin/org/fossify/clock/helpers/DBHelper.kt
+++ b/app/src/main/kotlin/org/fossify/clock/helpers/DBHelper.kt
@@ -10,6 +10,7 @@ import android.text.TextUtils
import org.fossify.clock.extensions.cancelAlarmClock
import org.fossify.clock.extensions.createNewAlarm
import org.fossify.clock.models.Alarm
+import org.fossify.clock.models.Group
import org.fossify.commons.extensions.getIntValue
import org.fossify.commons.extensions.getStringValue
import org.fossify.commons.helpers.FRIDAY_BIT
@@ -34,11 +35,16 @@ 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 GROUPS_TABLE_NAME = "alarm_groups"
+ private val COL_GROUP_ID = "group_id"
+ private val COL_GROUP_REF = "group_ref"
+ private val COL_GROUP_TITLE = "title"
+
private val mDb = writableDatabase
companion object {
- private const val DB_VERSION = 2
+ private const val DB_VERSION = 3
const val DB_NAME = "alarms.db"
@SuppressLint("StaticFieldLeak")
@@ -55,16 +61,30 @@ 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_GROUP_ID INTEGER)"
)
+ createGroupsTable(db)
insertInitialAlarms(db)
}
+ private fun createGroupsTable(db: SQLiteDatabase) {
+ db.execSQL(
+ "CREATE TABLE IF NOT EXISTS $GROUPS_TABLE_NAME ($COL_ID INTEGER PRIMARY KEY AUTOINCREMENT, " +
+ "$COL_GROUP_REF INTEGER, $COL_GROUP_TITLE TEXT)"
+ )
+ }
+
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
if (oldVersion == 1 && newVersion > oldVersion) {
db.execSQL("ALTER TABLE $ALARMS_TABLE_NAME ADD COLUMN $COL_ONE_SHOT INTEGER NOT NULL DEFAULT 0")
}
+ if (oldVersion < 3) {
+ db.execSQL("ALTER TABLE $GROUPS_TABLE_NAME ADD COLUMN $COL_GROUP_REF INTEGER DEFAULT 0")
+ createGroupsTable(db)
+ }
}
private fun insertInitialAlarms(db: SQLiteDatabase) {
@@ -107,6 +127,71 @@ class DBHelper private constructor(
mDb.delete(ALARMS_TABLE_NAME, selection, null)
}
+ fun insertGroup(title: String, ref: Int = 0): Int {
+ val values = ContentValues().apply {
+ put(COL_GROUP_TITLE, title)
+ }
+ val res = mDb.insert(GROUPS_TABLE_NAME, null, values).toInt()
+ if (res == -1) {
+ return -1
+ }
+
+ val finalRef = if (ref == 0) res else ref
+ if (ref == 0) {
+ updateGroup(Group(res, res, title))
+ } else {
+ updateGroup(Group(res, ref, title))
+ }
+
+ return finalRef
+ }
+
+ fun updateGroup(group: Group) {
+ val values = ContentValues().apply {
+ put(COL_GROUP_TITLE, group.title)
+ put(COL_GROUP_REF, group.ref) }
+ mDb.update(GROUPS_TABLE_NAME, values, "$COL_ID = ?", arrayOf(group.id.toString()))
+ }
+
+ fun deleteGroup(groupRef: Int) {
+ val values = ContentValues().apply { putNull(COL_GROUP_ID) }
+ mDb.update(ALARMS_TABLE_NAME, values, "$COL_GROUP_ID = ?", arrayOf(groupRef.toString()))
+ mDb.delete(GROUPS_TABLE_NAME, "$COL_GROUP_REF = ?", arrayOf(groupRef.toString()))
+ }
+ fun getGroups(): ArrayList {
+ val groups = ArrayList()
+ val cursor = mDb.query(GROUPS_TABLE_NAME, arrayOf(COL_ID, COL_GROUP_REF, COL_GROUP_TITLE),
+ null, null, null, null, null)
+ cursor.use {
+ if (it.moveToFirst()){
+ do {
+ groups.add(Group(id = it.getIntValue(COL_ID), ref = it.getIntValue(COL_GROUP_REF),
+ title = it.getStringValue(COL_GROUP_TITLE)))
+ } while (it.moveToNext())
+ }
+ }
+
+ return groups
+ }
+
+ fun assignAlarmsToGroup(alarmIds: List, groupRef: Int) {
+ val values = ContentValues().apply {
+ if (groupRef == 0) {
+ putNull(COL_GROUP_ID)
+ } else {
+ put(COL_GROUP_ID, groupRef)
+ }
+ }
+ val args = TextUtils.join(", ", alarmIds)
+ mDb.update(ALARMS_TABLE_NAME, values, "$COL_ID IN ($args)", null)
+ }
+
+ fun updateGroupEnabledState(groupRef: Int, isEnabled: Boolean): List {
+ val values = ContentValues().apply { put(COL_IS_ENABLED, isEnabled) }
+ mDb.update(ALARMS_TABLE_NAME, values, "$COL_GROUP_ID = ?", arrayOf(groupRef.toString()))
+ return getAlarms().filter { it.groupRef == groupRef }
+ }
+
fun getAlarmWithId(id: Int) = getAlarms().firstOrNull { it.id == id }
fun getAlarmsWithUri(uri: String) = getAlarms().filter { it.soundUri == uri }
@@ -121,6 +206,7 @@ class DBHelper private constructor(
put(COL_SOUND_URI, alarm.soundUri)
put(COL_LABEL, alarm.label)
put(COL_ONE_SHOT, alarm.oneShot)
+ put(COL_GROUP_ID, alarm.groupRef)
}
}
@@ -137,7 +223,8 @@ class DBHelper private constructor(
COL_SOUND_TITLE,
COL_SOUND_URI,
COL_LABEL,
- COL_ONE_SHOT
+ COL_ONE_SHOT,
+ COL_GROUP_ID
)
var cursor: Cursor? = null
try {
@@ -154,6 +241,7 @@ 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 groupId = cursor.getIntValue(COL_GROUP_ID)
val alarm = Alarm(
id = id,
@@ -164,7 +252,8 @@ class DBHelper private constructor(
soundTitle = soundTitle,
soundUri = soundUri,
label = label,
- oneShot = oneShot
+ oneShot = oneShot,
+ groupRef = groupId
)
alarms.add(alarm)
} catch (e: Exception) {
diff --git a/app/src/main/kotlin/org/fossify/clock/helpers/ImportHelper.kt b/app/src/main/kotlin/org/fossify/clock/helpers/ImportHelper.kt
index bcb524d76..e8bd2919f 100644
--- a/app/src/main/kotlin/org/fossify/clock/helpers/ImportHelper.kt
+++ b/app/src/main/kotlin/org/fossify/clock/helpers/ImportHelper.kt
@@ -6,6 +6,7 @@ import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import org.fossify.clock.models.Alarm
+import org.fossify.clock.models.Group
import org.fossify.clock.models.AlarmTimerBackup
import org.fossify.clock.models.Timer
import org.fossify.commons.extensions.showErrorToast
@@ -27,11 +28,13 @@ class ImportHelper(
return try {
context.contentResolver.openInputStream(uri)?.use { inputStream ->
val backup = Json.decodeFromStream(inputStream)
- val importedAlarms = insertAlarms(backup.alarms)
+ val importedGroups = insertGroups(backup.groups)
+ val updatedAlarms = updateAlarmGroupIds(backup.alarms, backup.groups)
+ val importedAlarms = insertAlarms(updatedAlarms)
val importedTimers = insertTimers(backup.timers)
when {
- importedAlarms > 0 || importedTimers > 0 -> ImportResult.IMPORT_OK
- importedAlarms == 0 && importedTimers == 0 -> ImportResult.IMPORT_INCOMPLETE
+ importedGroups > 0 || importedAlarms > 0 || importedTimers > 0 -> ImportResult.IMPORT_OK
+ importedGroups == 0 && importedAlarms == 0 && importedTimers == 0 -> ImportResult.IMPORT_INCOMPLETE
else -> ImportResult.IMPORT_FAIL
}
} ?: ImportResult.IMPORT_FAIL
@@ -45,7 +48,9 @@ class ImportHelper(
val existingAlarms = dbHelper.getAlarms()
var insertedCount = 0
alarms.forEach { alarm ->
- if (!isAlarmAlreadyInserted(alarm, existingAlarms)) {
+ if (isAlarmAlreadyInserted(alarm, existingAlarms)) {
+ dbHelper.updateAlarm(alarm)
+ } else {
if (dbHelper.insertAlarm(alarm) != -1) {
insertedCount++
}
@@ -54,6 +59,37 @@ class ImportHelper(
return insertedCount
}
+ private fun updateAlarmGroupIds(alarms: List, backupGroups: List): List {
+ val localGroups = dbHelper.getGroups()
+ alarms.forEach { alarm ->
+ if (alarm.groupRef != 0) {
+ val backupGroup = backupGroups.find { it.ref == alarm.groupRef }
+ if (backupGroup != null) {
+ val localGroup = localGroups.find { it.title == backupGroup.title }
+ if (localGroup != null) {
+ alarm.groupRef = localGroup.ref
+ }
+ }
+ }
+ }
+
+ return alarms
+ }
+
+ private fun insertGroups(groups: List): Int {
+ val existingGroups = dbHelper.getGroups()
+ var insertedCount = 0
+ groups.forEach { group ->
+ if (!isGroupInserted(group, existingGroups)){
+ val newId = dbHelper.insertGroup(group.title, group.ref)
+ if (newId != -1){
+ insertedCount++
+ }
+ }
+ }
+ return insertedCount
+ }
+
private fun insertTimers(timers: List): Int {
var insertedCount = 0
timers.forEach { timer ->
@@ -85,6 +121,16 @@ class ImportHelper(
alarm.label == existingAlarm.label &&
alarm.oneShot == existingAlarm.oneShot
) {
+ alarm.id = existingAlarm.id
+ return true
+ }
+ }
+ return false
+ }
+
+ private fun isGroupInserted(group: Group, existingGroups: List): Boolean{
+ for (existingGroup in existingGroups) {
+ if (group.title == existingGroup.title) {
return true
}
}
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..b2d658a25 100644
--- a/app/src/main/kotlin/org/fossify/clock/models/Alarm.kt
+++ b/app/src/main/kotlin/org/fossify/clock/models/Alarm.kt
@@ -16,6 +16,7 @@ data class Alarm(
var soundUri: String,
var label: String,
var oneShot: Boolean = false,
+ var groupRef: Int = 0,
) {
fun isRecurring() = days > 0
diff --git a/app/src/main/kotlin/org/fossify/clock/models/AlarmListItem.kt b/app/src/main/kotlin/org/fossify/clock/models/AlarmListItem.kt
new file mode 100644
index 000000000..7b64ce223
--- /dev/null
+++ b/app/src/main/kotlin/org/fossify/clock/models/AlarmListItem.kt
@@ -0,0 +1,6 @@
+package org.fossify.clock.models
+
+sealed class AlarmListItem {
+ data class AlarmRow(val alarm: Alarm) : AlarmListItem()
+ data class GroupRow(val group: Group, val alarms: List) : AlarmListItem()
+}
diff --git a/app/src/main/kotlin/org/fossify/clock/models/AlarmTimerBackup.kt b/app/src/main/kotlin/org/fossify/clock/models/AlarmTimerBackup.kt
index c7deac165..0a0486f28 100644
--- a/app/src/main/kotlin/org/fossify/clock/models/AlarmTimerBackup.kt
+++ b/app/src/main/kotlin/org/fossify/clock/models/AlarmTimerBackup.kt
@@ -6,5 +6,6 @@ import androidx.annotation.Keep
@kotlinx.serialization.Serializable
data class AlarmTimerBackup(
val alarms: List,
+ val groups: List,
val timers: List,
)
diff --git a/app/src/main/kotlin/org/fossify/clock/models/Group.kt b/app/src/main/kotlin/org/fossify/clock/models/Group.kt
new file mode 100644
index 000000000..f68e40385
--- /dev/null
+++ b/app/src/main/kotlin/org/fossify/clock/models/Group.kt
@@ -0,0 +1,13 @@
+package org.fossify.clock.models
+
+import androidx.annotation.Keep
+import kotlinx.serialization.KSerializer
+
+@Keep
+@kotlinx.serialization.Serializable
+data class Group (
+ var id: Int,
+ var ref: Int,
+ var title: String,
+) {
+}
diff --git a/app/src/main/res/layout/activity_group.xml b/app/src/main/res/layout/activity_group.xml
new file mode 100644
index 000000000..851b44a57
--- /dev/null
+++ b/app/src/main/res/layout/activity_group.xml
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/dialog_add_to_group.xml b/app/src/main/res/layout/dialog_add_to_group.xml
new file mode 100644
index 000000000..6a39a9966
--- /dev/null
+++ b/app/src/main/res/layout/dialog_add_to_group.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/dialog_rename_group.xml b/app/src/main/res/layout/dialog_rename_group.xml
new file mode 100644
index 000000000..5c6a899d3
--- /dev/null
+++ b/app/src/main/res/layout/dialog_rename_group.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_alarm_group.xml b/app/src/main/res/layout/item_alarm_group.xml
new file mode 100644
index 000000000..a23358549
--- /dev/null
+++ b/app/src/main/res/layout/item_alarm_group.xml
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/menu/cab_alarms.xml b/app/src/main/res/menu/cab_alarms.xml
index 9e2f21475..0e1597640 100644
--- a/app/src/main/res/menu/cab_alarms.xml
+++ b/app/src/main/res/menu/cab_alarms.xml
@@ -1,6 +1,11 @@