diff --git a/gradle.properties b/gradle.properties
index cfc7ec4b1..2035e4f83 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -6,3 +6,4 @@ android.enableJetifier=true
android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=false
android.nonFinalResIds=false
+org.gradle.java.installations.auto-download=true
diff --git a/uhabits-android/src/androidTest/java/org/isoron/uhabits/activities/habits/list/views/HabitCardViewTest.kt b/uhabits-android/src/androidTest/java/org/isoron/uhabits/activities/habits/list/views/HabitCardViewTest.kt
index 20474090a..ad237f630 100644
--- a/uhabits-android/src/androidTest/java/org/isoron/uhabits/activities/habits/list/views/HabitCardViewTest.kt
+++ b/uhabits-android/src/androidTest/java/org/isoron/uhabits/activities/habits/list/views/HabitCardViewTest.kt
@@ -53,7 +53,7 @@ class HabitCardViewTest : BaseViewTest() {
.getByInterval(today.minus(300), today)
.map { it.value }.toIntArray()
- view = component.getHabitCardViewFactory().create().apply {
+ view = component.getHabitCardViewFactory().createHabitCard().apply {
habit = habit1
values = entries
score = habit1.scores[today].value
diff --git a/uhabits-android/src/main/AndroidManifest.xml b/uhabits-android/src/main/AndroidManifest.xml
index 8c7758439..1f3aba1aa 100644
--- a/uhabits-android/src/main/AndroidManifest.xml
+++ b/uhabits-android/src/main/AndroidManifest.xml
@@ -42,6 +42,22 @@
android:value=".activities.habits.list.ListHabitsActivity" />
+
+
+
+
+
+
+
+
@@ -72,6 +88,14 @@
android:value=".activities.habits.list.ListHabitsActivity" />
+
+
+
+
@@ -112,6 +136,15 @@
+
+
+
+
+
+
diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/HabitsApplication.kt b/uhabits-android/src/main/java/org/isoron/uhabits/HabitsApplication.kt
index 4f00f4d1f..b8421c5c1 100644
--- a/uhabits-android/src/main/java/org/isoron/uhabits/HabitsApplication.kt
+++ b/uhabits-android/src/main/java/org/isoron/uhabits/HabitsApplication.kt
@@ -79,6 +79,10 @@ class HabitsApplication : Application() {
val habitList = component.habitList
for (h in habitList) h.recompute()
+ val habitGroupList = component.habitGroupList
+ for (hgr in habitGroupList) hgr.recompute()
+ habitGroupList.attachHabitsToGroups()
+
widgetUpdater = component.widgetUpdater.apply {
startListening()
scheduleStartDayWidgetUpdate()
diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/HabitsDirFinder.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/HabitsDirFinder.kt
index 953ee554c..4692799c4 100644
--- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/HabitsDirFinder.kt
+++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/HabitsDirFinder.kt
@@ -20,6 +20,7 @@ package org.isoron.uhabits.activities
import org.isoron.uhabits.AndroidDirFinder
import org.isoron.uhabits.core.ui.screens.habits.list.ListHabitsBehavior
+import org.isoron.uhabits.core.ui.screens.habits.show.ShowHabitGroupMenuPresenter
import org.isoron.uhabits.core.ui.screens.habits.show.ShowHabitMenuPresenter
import java.io.File
import javax.inject.Inject
@@ -33,3 +34,13 @@ constructor(
return androidDirFinder.getFilesDir("CSV")!!
}
}
+
+class HabitGroupsDirFinder @Inject
+constructor(
+ private val androidDirFinder: AndroidDirFinder
+) : ShowHabitGroupMenuPresenter.System, ListHabitsBehavior.DirFinder {
+
+ override fun getCSVOutputDir(): File {
+ return androidDirFinder.getFilesDir("CSV")!!
+ }
+}
diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/edit/EditHabitActivity.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/edit/EditHabitActivity.kt
index 5479ab78c..207f39195 100644
--- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/edit/EditHabitActivity.kt
+++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/edit/EditHabitActivity.kt
@@ -44,8 +44,9 @@ import org.isoron.uhabits.activities.common.dialogs.WeekdayPickerDialog
import org.isoron.uhabits.core.commands.CommandRunner
import org.isoron.uhabits.core.commands.CreateHabitCommand
import org.isoron.uhabits.core.commands.EditHabitCommand
+import org.isoron.uhabits.core.commands.RefreshParentGroupCommand
import org.isoron.uhabits.core.models.Frequency
-import org.isoron.uhabits.core.models.Habit
+import org.isoron.uhabits.core.models.HabitGroup
import org.isoron.uhabits.core.models.HabitType
import org.isoron.uhabits.core.models.NumericalHabitType
import org.isoron.uhabits.core.models.PaletteColor
@@ -76,6 +77,7 @@ class EditHabitActivity : AppCompatActivity() {
var habitId = -1L
lateinit var habitType: HabitType
+ var parentGroup: HabitGroup? = null
var unit = ""
var color = PaletteColor(11)
var androidColor = 0
@@ -98,10 +100,21 @@ class EditHabitActivity : AppCompatActivity() {
binding.toolbar.applyToolbarInsets()
setContentView(binding.root)
+ if (intent.hasExtra("groupId")) {
+ val groupId = intent.getLongExtra("groupId", -1L)
+ parentGroup = component.habitGroupList.getById(groupId)
+ color = parentGroup!!.color
+ }
+
if (intent.hasExtra("habitId")) {
binding.toolbar.title = getString(R.string.edit_habit)
- habitId = intent.getLongExtra("habitId", -1)
- val habit = component.habitList.getById(habitId)!!
+ habitId = intent.getLongExtra("habitId", -1L)
+ val habitList = if (parentGroup != null) {
+ parentGroup!!.habitList
+ } else {
+ component.habitList
+ }
+ val habit = habitList.getById(habitId)!!
habitType = habit.type
color = habit.color
freqNum = habit.frequency.numerator
@@ -262,9 +275,14 @@ class EditHabitActivity : AppCompatActivity() {
val component = (application as HabitsApplication).component
val habit = component.modelFactory.buildHabit()
- var original: Habit? = null
- if (habitId >= 0) {
- original = component.habitList.getById(habitId)!!
+ val habitList = if (parentGroup != null) {
+ parentGroup!!.habitList
+ } else {
+ component.habitList
+ }
+
+ if (habitId > 0) {
+ val original = habitList.getById(habitId)!!
habit.copyFrom(original)
}
@@ -285,21 +303,31 @@ class EditHabitActivity : AppCompatActivity() {
habit.unit = binding.unitInput.text.trim().toString()
}
habit.type = habitType
+ habit.group = parentGroup
+ habit.groupId = parentGroup?.id
+ habit.groupUUID = parentGroup?.uuid
- val command = if (habitId >= 0) {
+ val command = if (habitId > 0) {
EditHabitCommand(
- component.habitList,
+ habitList,
habitId,
habit
)
} else {
CreateHabitCommand(
component.modelFactory,
- component.habitList,
+ habitList,
habit
)
}
component.commandRunner.run(command)
+
+ if (habit.groupId != null) {
+ val habitGroupList = component.habitGroupList
+ val refreshCommand = RefreshParentGroupCommand(habit, habitGroupList)
+ component.commandRunner.run(refreshCommand)
+ }
+
finish()
}
diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/edit/EditHabitGroupActivity.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/edit/EditHabitGroupActivity.kt
new file mode 100644
index 000000000..03761f26c
--- /dev/null
+++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/edit/EditHabitGroupActivity.kt
@@ -0,0 +1,232 @@
+package org.isoron.uhabits.activities.habits.edit
+
+import android.content.res.ColorStateList
+import android.graphics.Color
+import android.os.Bundle
+import android.text.Html
+import android.text.Spanned
+import android.text.format.DateFormat
+import android.view.View
+import androidx.annotation.StringRes
+import androidx.appcompat.app.AppCompatActivity
+import androidx.fragment.app.DialogFragment
+import com.android.datetimepicker.time.RadialPickerLayout
+import com.android.datetimepicker.time.TimePickerDialog
+import org.isoron.platform.gui.toInt
+import org.isoron.uhabits.HabitsApplication
+import org.isoron.uhabits.R
+import org.isoron.uhabits.activities.AndroidThemeSwitcher
+import org.isoron.uhabits.activities.common.dialogs.ColorPickerDialogFactory
+import org.isoron.uhabits.activities.common.dialogs.WeekdayPickerDialog
+import org.isoron.uhabits.core.commands.CommandRunner
+import org.isoron.uhabits.core.commands.CreateHabitGroupCommand
+import org.isoron.uhabits.core.commands.EditHabitGroupCommand
+import org.isoron.uhabits.core.models.HabitGroup
+import org.isoron.uhabits.core.models.PaletteColor
+import org.isoron.uhabits.core.models.Reminder
+import org.isoron.uhabits.core.models.WeekdayList
+import org.isoron.uhabits.databinding.ActivityEditHabitGroupBinding
+import org.isoron.uhabits.utils.ColorUtils
+import org.isoron.uhabits.utils.dismissCurrentAndShow
+import org.isoron.uhabits.utils.formatTime
+import org.isoron.uhabits.utils.toFormattedString
+
+class EditHabitGroupActivity : AppCompatActivity() {
+
+ private lateinit var themeSwitcher: AndroidThemeSwitcher
+ private lateinit var binding: ActivityEditHabitGroupBinding
+ private lateinit var commandRunner: CommandRunner
+
+ var habitGroupId = -1L
+ var color = PaletteColor(11)
+ var androidColor = 0
+ var reminderHour = -1
+ var reminderMin = -1
+ var reminderDays: WeekdayList = WeekdayList.EVERY_DAY
+
+ override fun onCreate(state: Bundle?) {
+ super.onCreate(state)
+
+ val component = (application as HabitsApplication).component
+ themeSwitcher = AndroidThemeSwitcher(this, component.preferences)
+ themeSwitcher.apply()
+
+ binding = ActivityEditHabitGroupBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ if (intent.hasExtra("habitGroupId")) {
+ binding.toolbar.title = getString(R.string.edit_habit_group)
+ habitGroupId = intent.getLongExtra("habitGroupId", -1)
+ val hgr = component.habitGroupList.getById(habitGroupId)!!
+ color = hgr.color
+ hgr.reminder?.let {
+ reminderHour = it.hour
+ reminderMin = it.minute
+ reminderDays = it.days
+ }
+ binding.nameInput.setText(hgr.name)
+ binding.questionInput.setText(hgr.question)
+ binding.notesInput.setText(hgr.description)
+ }
+
+ if (state != null) {
+ habitGroupId = state.getLong("habitGroupId")
+ color = PaletteColor(state.getInt("paletteColor"))
+ reminderHour = state.getInt("reminderHour")
+ reminderMin = state.getInt("reminderMin")
+ reminderDays = WeekdayList(state.getInt("reminderDays"))
+ }
+
+ updateColors()
+
+ setSupportActionBar(binding.toolbar)
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ supportActionBar?.setDisplayShowHomeEnabled(true)
+ supportActionBar?.elevation = 10.0f
+
+ val colorPickerDialogFactory = ColorPickerDialogFactory(this)
+ binding.colorButton.setOnClickListener {
+ val picker = colorPickerDialogFactory.create(color, themeSwitcher.currentTheme)
+ picker.setListener { paletteColor ->
+ this.color = paletteColor
+ updateColors()
+ }
+ picker.dismissCurrentAndShow(supportFragmentManager, "colorPicker")
+ }
+
+ populateReminder()
+ binding.reminderTimePicker.setOnClickListener {
+ val currentHour = if (reminderHour >= 0) reminderHour else 8
+ val currentMin = if (reminderMin >= 0) reminderMin else 0
+ val is24HourMode = DateFormat.is24HourFormat(this)
+ val dialog = TimePickerDialog.newInstance(
+ object : TimePickerDialog.OnTimeSetListener {
+ override fun onTimeSet(view: RadialPickerLayout?, hourOfDay: Int, minute: Int) {
+ reminderHour = hourOfDay
+ reminderMin = minute
+ populateReminder()
+ }
+
+ override fun onTimeCleared(view: RadialPickerLayout?) {
+ reminderHour = -1
+ reminderMin = -1
+ reminderDays = WeekdayList.EVERY_DAY
+ populateReminder()
+ }
+ },
+ currentHour,
+ currentMin,
+ is24HourMode,
+ androidColor
+ )
+ dialog.dismissCurrentAndShow(supportFragmentManager, "timePicker")
+ }
+
+ binding.reminderDatePicker.setOnClickListener {
+ val dialog = WeekdayPickerDialog()
+
+ dialog.setListener { days: WeekdayList ->
+ reminderDays = days
+ if (reminderDays.isEmpty) reminderDays = WeekdayList.EVERY_DAY
+ populateReminder()
+ }
+ dialog.setSelectedDays(reminderDays)
+ dialog.dismissCurrentAndShow(supportFragmentManager, "dayPicker")
+ }
+
+ binding.buttonSave.setOnClickListener {
+ if (validate()) save()
+ }
+
+ for (fragment in supportFragmentManager.fragments) {
+ (fragment as DialogFragment).dismiss()
+ }
+ }
+
+ private fun save() {
+ val component = (application as HabitsApplication).component
+ val hgr = component.modelFactory.buildHabitGroup()
+
+ var original: HabitGroup? = null
+ if (habitGroupId >= 0) {
+ original = component.habitGroupList.getById(habitGroupId)!!
+ hgr.copyFrom(original)
+ }
+
+ hgr.name = binding.nameInput.text.trim().toString()
+ hgr.question = binding.questionInput.text.trim().toString()
+ hgr.description = binding.notesInput.text.trim().toString()
+ hgr.color = color
+ if (reminderHour >= 0) {
+ hgr.reminder = Reminder(reminderHour, reminderMin, reminderDays)
+ } else {
+ hgr.reminder = null
+ }
+
+ val command = if (habitGroupId >= 0) {
+ EditHabitGroupCommand(
+ component.habitGroupList,
+ habitGroupId,
+ hgr
+ )
+ } else {
+ CreateHabitGroupCommand(
+ component.modelFactory,
+ component.habitGroupList,
+ hgr
+ )
+ }
+ component.commandRunner.run(command)
+ finish()
+ }
+
+ private fun validate(): Boolean {
+ var isValid = true
+ if (binding.nameInput.text.isEmpty()) {
+ binding.nameInput.error = getFormattedValidationError(R.string.validation_cannot_be_blank)
+ isValid = false
+ }
+ return isValid
+ }
+
+ private fun populateReminder() {
+ if (reminderHour < 0) {
+ binding.reminderTimePicker.text = getString(R.string.reminder_off)
+ binding.reminderDatePicker.visibility = View.GONE
+ binding.reminderDivider.visibility = View.GONE
+ } else {
+ val time = formatTime(this, reminderHour, reminderMin)
+ binding.reminderTimePicker.text = time
+ binding.reminderDatePicker.visibility = View.VISIBLE
+ binding.reminderDivider.visibility = View.VISIBLE
+ binding.reminderDatePicker.text = reminderDays.toFormattedString(this)
+ }
+ }
+
+ private fun updateColors() {
+ androidColor = themeSwitcher.currentTheme.color(color).toInt()
+ binding.colorButton.backgroundTintList = ColorStateList.valueOf(androidColor)
+ if (!themeSwitcher.isNightMode) {
+ val darkerAndroidColor = ColorUtils.mixColors(Color.BLACK, androidColor, 0.15f)
+ window.statusBarColor = darkerAndroidColor
+ binding.toolbar.setBackgroundColor(androidColor)
+ }
+ }
+
+ private fun getFormattedValidationError(@StringRes resId: Int): Spanned {
+ val html = "${getString(resId)}"
+ return Html.fromHtml(html)
+ }
+
+ override fun onSaveInstanceState(state: Bundle) {
+ super.onSaveInstanceState(state)
+ with(state) {
+ putLong("habitGroupId", habitGroupId)
+ putInt("paletteColor", color.paletteIndex)
+ putInt("androidColor", androidColor)
+ putInt("reminderHour", reminderHour)
+ putInt("reminderMin", reminderMin)
+ putInt("reminderDays", reminderDays.toInteger())
+ }
+ }
+}
diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/edit/HabitTypeDialog.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/edit/HabitTypeDialog.kt
index 350509207..603335c37 100644
--- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/edit/HabitTypeDialog.kt
+++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/edit/HabitTypeDialog.kt
@@ -29,7 +29,7 @@ import org.isoron.uhabits.core.models.HabitType
import org.isoron.uhabits.databinding.SelectHabitTypeBinding
import org.isoron.uhabits.intents.IntentFactory
-class HabitTypeDialog : AppCompatDialogFragment() {
+class HabitTypeDialog(val groupId: Long? = null) : AppCompatDialogFragment() {
override fun getTheme() = R.style.Translucent
override fun onCreateView(
@@ -40,17 +40,27 @@ class HabitTypeDialog : AppCompatDialogFragment() {
val binding = SelectHabitTypeBinding.inflate(inflater, container, false)
binding.buttonYesNo.setOnClickListener {
- val intent = IntentFactory().startEditActivity(requireActivity(), HabitType.YES_NO.value)
+ val intent = IntentFactory().startEditActivity(requireActivity(), HabitType.YES_NO.value, groupId)
startActivity(intent)
dismiss()
}
binding.buttonMeasurable.setOnClickListener {
- val intent = IntentFactory().startEditActivity(requireActivity(), HabitType.NUMERICAL.value)
+ val intent = IntentFactory().startEditActivity(requireActivity(), HabitType.NUMERICAL.value, groupId)
startActivity(intent)
dismiss()
}
+ binding.buttonHabitGroup.setOnClickListener {
+ val intent = IntentFactory().startEditGroupActivity(requireActivity())
+ startActivity(intent)
+ dismiss()
+ }
+
+ if (groupId != null) {
+ binding.buttonHabitGroup.visibility = View.GONE
+ }
+
binding.background.setOnClickListener {
dismiss()
}
diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/HabitGroupPickerDialog.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/HabitGroupPickerDialog.kt
new file mode 100644
index 000000000..aea309d83
--- /dev/null
+++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/HabitGroupPickerDialog.kt
@@ -0,0 +1,91 @@
+/*
+ * Copyright (C) 2016-2021 Álinson Santos Xavier
+ *
+ * This file is part of Loop Habit Tracker.
+ *
+ * Loop Habit Tracker 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.
+ *
+ * Loop Habit Tracker 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, see .
+ */
+
+package org.isoron.uhabits.activities.habits.list
+
+import android.app.Activity
+import android.os.Bundle
+import android.widget.ArrayAdapter
+import android.widget.ListView
+import android.widget.TextView
+import org.isoron.uhabits.HabitsApplication
+import org.isoron.uhabits.R
+import org.isoron.uhabits.activities.AndroidThemeSwitcher
+import org.isoron.uhabits.core.commands.AddToGroupCommand
+import org.isoron.uhabits.core.models.Habit
+import org.isoron.uhabits.core.models.HabitGroupList
+import org.isoron.uhabits.core.models.HabitList
+import org.isoron.uhabits.inject.HabitsApplicationComponent
+
+class HabitGroupPickerDialog : Activity() {
+
+ private lateinit var habitGroupList: HabitGroupList
+ private lateinit var habitList: HabitList
+ private lateinit var selected: List
+ private lateinit var component: HabitsApplicationComponent
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ component = (applicationContext as HabitsApplication).component
+ AndroidThemeSwitcher(this, component.preferences).apply()
+ habitList = component.habitList
+ habitGroupList = component.habitGroupList
+
+ val selectedIds = intent.getLongArrayExtra("selected")!!
+ selected = selectedIds.map { id ->
+ habitList.getById(id) ?: habitGroupList.getHabitByID(id)!!
+ }
+
+ val groupIds = ArrayList()
+ val groupNames = ArrayList()
+
+ for (hgr in habitGroupList) {
+ if (hgr.isArchived) continue
+ groupIds.add(hgr.id!!)
+ groupNames.add(hgr.name)
+ }
+
+ if (groupNames.isEmpty()) {
+ setContentView(R.layout.widget_empty_activity)
+ findViewById(R.id.message).setText(R.string.no_habit_groups)
+ return
+ }
+
+ setContentView(R.layout.widget_configure_activity)
+ val listView = findViewById(R.id.listView)
+
+ with(listView) {
+ adapter = ArrayAdapter(
+ context,
+ android.R.layout.simple_list_item_1,
+ groupNames
+ )
+ setOnItemClickListener { parent, view, position, id ->
+ performTransfer(groupIds[position])
+ }
+ }
+ }
+ private fun performTransfer(toGroupId: Long) {
+ val hgr = habitGroupList.getById(toGroupId)!!
+ selected = selected.filter { it.groupId != hgr.id }
+ component.commandRunner.run(
+ AddToGroupCommand(habitList, hgr, selected)
+ )
+ finish()
+ }
+}
diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsActivity.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsActivity.kt
index d16f7d702..586811032 100644
--- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsActivity.kt
+++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsActivity.kt
@@ -179,10 +179,20 @@ class ListHabitsActivity : AppCompatActivity(), Preferences.Listener {
val habitId = intent.extras?.getLong("habit")
val timestamp = intent.extras?.getLong("timestamp")
if (habitId != null && timestamp != null) {
- val habit = appComponent.habitList.getById(habitId)!!
+ val habit = appComponent.habitList.getById(habitId) ?: appComponent.habitGroupList.getHabitByID(habitId)!!
component.listHabitsBehavior.onEdit(habit, Timestamp(timestamp))
}
}
+ intent.getLongExtra("CLEAR_NOTIFICATION_HABIT_ID", -1).takeIf { it != -1L }?.let { id ->
+ val dismissHabit = appComponent.habitList.getById(id) ?: appComponent.habitGroupList.getHabitByID(id)
+ if (dismissHabit != null) {
+ appComponent.reminderController.onDismiss(dismissHabit)
+ } else {
+ val dismissHabitGroup = appComponent.habitGroupList.getById(id)!!
+ appComponent.reminderController.onDismiss(dismissHabitGroup)
+ }
+ }
+
intent = null
}
diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsRootView.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsRootView.kt
index f0a542a0d..35de927d4 100644
--- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsRootView.kt
+++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsRootView.kt
@@ -148,7 +148,7 @@ class ListHabitsRootView @Inject constructor(
private fun updateEmptyView() {
if (listAdapter.itemCount == 0) {
- if (listAdapter.hasNoHabit()) {
+ if (listAdapter.hasNoHabit() && listAdapter.hasNoHabitGroup()) {
llEmpty.showEmpty()
} else {
llEmpty.showDone()
diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsScreen.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsScreen.kt
index e1f0418a3..8feb12748 100644
--- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsScreen.kt
+++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsScreen.kt
@@ -41,10 +41,12 @@ import org.isoron.uhabits.core.commands.ChangeHabitColorCommand
import org.isoron.uhabits.core.commands.Command
import org.isoron.uhabits.core.commands.CommandRunner
import org.isoron.uhabits.core.commands.CreateHabitCommand
+import org.isoron.uhabits.core.commands.DeleteHabitGroupsCommand
import org.isoron.uhabits.core.commands.DeleteHabitsCommand
import org.isoron.uhabits.core.commands.EditHabitCommand
import org.isoron.uhabits.core.commands.UnarchiveHabitsCommand
import org.isoron.uhabits.core.models.Habit
+import org.isoron.uhabits.core.models.HabitGroup
import org.isoron.uhabits.core.models.PaletteColor
import org.isoron.uhabits.core.preferences.Preferences
import org.isoron.uhabits.core.tasks.TaskRunner
@@ -164,8 +166,8 @@ class ListHabitsScreen
activity.startActivity(intent)
}
- override fun showSelectHabitTypeDialog() {
- val dialog = HabitTypeDialog()
+ override fun showSelectHabitTypeDialog(groupId: Long?) {
+ val dialog = HabitTypeDialog(groupId)
dialog.show(activity.supportFragmentManager, "habitType")
}
@@ -178,6 +180,16 @@ class ListHabitsScreen
activity.startActivity(intent)
}
+ override fun showEditHabitGroupScreen(selected: List) {
+ val intent = intentFactory.startEditGroupActivity(activity, selected[0])
+ activity.startActivity(intent)
+ }
+
+ override fun showHabitGroupPickerDialog(selected: List) {
+ val intent = intentFactory.startHabitGroupPickerActivity(activity, selected)
+ activity.startActivity(intent)
+ }
+
override fun showFAQScreen() {
val intent = intentFactory.viewFAQ(activity)
activity.startActivity(intent)
@@ -188,6 +200,11 @@ class ListHabitsScreen
activity.startActivity(intent)
}
+ override fun showHabitGroupScreen(hgr: HabitGroup) {
+ val intent = intentFactory.startShowHabitGroupActivity(activity, hgr)
+ activity.startActivity(intent)
+ }
+
fun showImportScreen() {
val intent = intentFactory.openDocument()
activity.startActivityForResult(intent, REQUEST_OPEN_DOCUMENT)
@@ -312,6 +329,12 @@ class ListHabitsScreen
command.selected.size
)
}
+ is DeleteHabitGroupsCommand -> {
+ return activity.resources.getQuantityString(
+ R.plurals.toast_habits_deleted,
+ command.selected.size
+ )
+ }
is EditHabitCommand -> {
return activity.resources.getQuantityString(R.plurals.toast_habits_changed, 1)
}
diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsSelectionMenu.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsSelectionMenu.kt
index ce5bc45a0..35c5563bc 100644
--- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsSelectionMenu.kt
+++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsSelectionMenu.kt
@@ -75,14 +75,18 @@ class ListHabitsSelectionMenu @Inject constructor(
val itemColor = menu.findItem(R.id.action_color)
val itemArchive = menu.findItem(R.id.action_archive_habit)
val itemUnarchive = menu.findItem(R.id.action_unarchive_habit)
+ val itemRemoveFromGroup = menu.findItem(R.id.action_remove_from_group)
+ val itemAddToGroup = menu.findItem(R.id.action_add_to_group)
val itemNotify = menu.findItem(R.id.action_notify)
itemColor.isVisible = true
itemEdit.isVisible = behavior.canEdit()
itemArchive.isVisible = behavior.canArchive()
itemUnarchive.isVisible = behavior.canUnarchive()
+ itemRemoveFromGroup.isVisible = behavior.areSubHabits()
+ itemAddToGroup.isVisible = behavior.areHabits()
itemNotify.isVisible = prefs.isDeveloper
- activeActionMode?.title = listAdapter.selected.size.toString()
+ activeActionMode?.title = (listAdapter.selectedHabits.size + listAdapter.selectedHabitGroups.size).toString()
return true
}
override fun onDestroyActionMode(mode: ActionMode?) {
@@ -106,6 +110,16 @@ class ListHabitsSelectionMenu @Inject constructor(
return true
}
+ R.id.action_remove_from_group -> {
+ behavior.onRemoveFromGroup()
+ return true
+ }
+
+ R.id.action_add_to_group -> {
+ behavior.onAddToGroup()
+ return true
+ }
+
R.id.action_delete -> {
behavior.onDeleteHabits()
return true
@@ -117,7 +131,7 @@ class ListHabitsSelectionMenu @Inject constructor(
}
R.id.action_notify -> {
- for (h in listAdapter.selected)
+ for (h in listAdapter.selectedHabits)
notificationTray.show(h, DateUtils.getToday(), 0)
return true
}
diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/AddButtonView.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/AddButtonView.kt
new file mode 100644
index 000000000..0034d2b94
--- /dev/null
+++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/AddButtonView.kt
@@ -0,0 +1,73 @@
+package org.isoron.uhabits.activities.habits.list.views
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.graphics.RectF
+import android.text.TextPaint
+import android.view.View
+import android.view.View.MeasureSpec.EXACTLY
+import org.isoron.uhabits.R
+import org.isoron.uhabits.activities.habits.list.ListHabitsActivity
+import org.isoron.uhabits.core.models.HabitGroup
+import org.isoron.uhabits.utils.getFontAwesome
+import org.isoron.uhabits.utils.sp
+import org.isoron.uhabits.utils.sres
+import org.isoron.uhabits.utils.toMeasureSpec
+
+class AddButtonView(
+ context: Context,
+ var habitGroup: HabitGroup?
+) : View(context),
+ View.OnClickListener {
+
+ private var drawer = Drawer()
+
+ init {
+ setOnClickListener(this)
+ }
+
+ override fun onClick(v: View) {
+ (context as ListHabitsActivity).component.listHabitsMenu.behavior.onCreateHabit(habitGroup!!.id)
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ super.onDraw(canvas)
+ drawer.draw(canvas)
+ }
+
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+ val height = resources.getDimensionPixelSize(R.dimen.checkmarkHeight)
+ val width = resources.getDimensionPixelSize(R.dimen.checkmarkWidth)
+ super.onMeasure(
+ width.toMeasureSpec(EXACTLY),
+ height.toMeasureSpec(EXACTLY)
+ )
+ }
+
+ private inner class Drawer {
+ private val rect = RectF()
+ private val highContrastColor = sres.getColor(R.attr.contrast80)
+
+ private val paint = TextPaint().apply {
+ typeface = getFontAwesome()
+ isAntiAlias = true
+ textAlign = Paint.Align.CENTER
+ }
+
+ fun draw(canvas: Canvas) {
+ paint.color = highContrastColor
+ val id = R.string.fa_plus
+ paint.textSize = sp(12.0f)
+ paint.strokeWidth = 0f
+ paint.style = Paint.Style.FILL
+
+ val label = resources.getString(id)
+ val em = paint.measureText("m")
+
+ rect.set(0f, 0f, width.toFloat(), height.toFloat())
+ rect.offset(0f, 0.4f * em)
+ canvas.drawText(label, rect.centerX(), rect.centerY(), paint)
+ }
+ }
+}
diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/CollapseButtonView.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/CollapseButtonView.kt
new file mode 100644
index 000000000..ab11baf3d
--- /dev/null
+++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/CollapseButtonView.kt
@@ -0,0 +1,105 @@
+package org.isoron.uhabits.activities.habits.list.views
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.graphics.RectF
+import android.text.TextPaint
+import android.view.View
+import org.isoron.uhabits.R
+import org.isoron.uhabits.activities.habits.list.ListHabitsActivity
+import org.isoron.uhabits.core.models.HabitGroup
+import org.isoron.uhabits.core.models.ModelObservable
+import org.isoron.uhabits.utils.getFontAwesome
+import org.isoron.uhabits.utils.sp
+import org.isoron.uhabits.utils.sres
+import org.isoron.uhabits.utils.toMeasureSpec
+
+class CollapseButtonView(
+ context: Context,
+ var habitGroup: HabitGroup?
+) : View(context),
+ View.OnClickListener,
+ ModelObservable.Listener {
+
+ private var drawer = Drawer()
+
+ var collapsed = false
+ set(value) {
+ field = value
+ drawer.rotate()
+ invalidate()
+ }
+
+ init {
+ setOnClickListener(this)
+ }
+
+ override fun onClick(v: View) {
+ collapsed = !collapsed
+ habitGroup!!.collapsed = collapsed
+ (context as ListHabitsActivity).component.listHabitsMenu.behavior.onPreferencesChanged()
+ invalidate()
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ super.onDraw(canvas)
+ drawer.draw(canvas)
+ }
+
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+ val height = resources.getDimensionPixelSize(R.dimen.checkmarkHeight)
+ val width = resources.getDimensionPixelSize(R.dimen.checkmarkWidth)
+ super.onMeasure(
+ width.toMeasureSpec(MeasureSpec.EXACTLY),
+ height.toMeasureSpec(MeasureSpec.EXACTLY)
+ )
+ }
+
+ private inner class Drawer {
+ private val rect = RectF()
+ private val highContrastColor = sres.getColor(R.attr.contrast100)
+
+ private var rotationAngle = 0f
+ private var offset_y = 0.4f
+ private var offset_x = 0f
+ private val paint = TextPaint().apply {
+ typeface = getFontAwesome()
+ isAntiAlias = true
+ textAlign = Paint.Align.CENTER
+ }
+
+ fun rotate() {
+ if (collapsed) {
+ rotationAngle = 90f
+ offset_y = 0f
+ offset_x = -0.4f
+ } else {
+ rotationAngle = 0f
+ offset_y = 0.4f
+ offset_x = 0f
+ }
+ }
+
+ fun draw(canvas: Canvas) {
+ paint.color = highContrastColor
+ val id = R.string.fa_angle_down
+ paint.textSize = sp(12.0f)
+ paint.strokeWidth = 0f
+ paint.style = Paint.Style.FILL
+
+ val label = resources.getString(id)
+ val em = paint.measureText("m")
+
+ rect.set(0f, 0f, width.toFloat(), height.toFloat())
+ rect.offset(offset_x * em, offset_y * em)
+
+ canvas.save() // Save the current state of the canvas
+ canvas.rotate(rotationAngle, rect.centerX(), rect.centerY()) // Rotate the canvas
+ canvas.drawText(label, rect.centerX(), rect.centerY(), paint)
+ canvas.restore()
+ }
+ }
+
+ override fun onModelChange() {}
+}
diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardListAdapter.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardListAdapter.kt
index 06412103b..fcff247a2 100644
--- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardListAdapter.kt
+++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardListAdapter.kt
@@ -18,10 +18,12 @@
*/
package org.isoron.uhabits.activities.habits.list.views
+import android.annotation.SuppressLint
import android.view.ViewGroup
-import androidx.recyclerview.widget.RecyclerView
+import androidx.recyclerview.widget.RecyclerView.Adapter
import org.isoron.uhabits.activities.habits.list.MAX_CHECKMARK_COUNT
import org.isoron.uhabits.core.models.Habit
+import org.isoron.uhabits.core.models.HabitGroup
import org.isoron.uhabits.core.models.HabitList
import org.isoron.uhabits.core.models.HabitMatcher
import org.isoron.uhabits.core.models.ModelObservable
@@ -38,7 +40,7 @@ import javax.inject.Inject
* Provides data that backs a [HabitCardListView].
*
*
- * The data if fetched and cached by a [HabitCardListCache]. This adapter
+ * The data is fetched and cached by a [HabitCardListCache]. This adapter
* also holds a list of items that have been selected.
*/
@ActivityScope
@@ -46,14 +48,16 @@ class HabitCardListAdapter @Inject constructor(
private val cache: HabitCardListCache,
private val preferences: Preferences,
private val midnightTimer: MidnightTimer
-) : RecyclerView.Adapter(),
+) : Adapter(),
HabitCardListCache.Listener,
MidnightTimer.MidnightListener,
ListHabitsMenuBehavior.Adapter,
ListHabitsSelectionMenuBehavior.Adapter {
val observable: ModelObservable = ModelObservable()
private var listView: HabitCardListView? = null
- val selected: LinkedList = LinkedList()
+ val selectedHabits: LinkedList = LinkedList()
+ val selectedHabitGroups: LinkedList = LinkedList()
+
override fun atMidnight() {
cache.refreshAllHabits()
}
@@ -66,17 +70,27 @@ class HabitCardListAdapter @Inject constructor(
return cache.hasNoHabit()
}
+ fun hasNoHabitGroup(): Boolean {
+ return cache.hasNoHabitGroup()
+ }
+
/**
* Sets all items as not selected.
*/
+ @SuppressLint("NotifyDataSetChanged")
override fun clearSelection() {
- selected.clear()
+ selectedHabits.clear()
+ selectedHabitGroups.clear()
notifyDataSetChanged()
observable.notifyListeners()
}
- override fun getSelected(): List {
- return ArrayList(selected)
+ override fun getSelectedHabits(): List {
+ return ArrayList(selectedHabits)
+ }
+
+ override fun getSelectedHabitGroups(): List {
+ return ArrayList(selectedHabitGroups)
}
/**
@@ -90,12 +104,20 @@ class HabitCardListAdapter @Inject constructor(
return cache.getHabitByPosition(position)
}
+ fun getHabit(position: Int): Habit? {
+ return cache.getHabitByPosition(position)
+ }
+
+ fun getHabitGroup(position: Int): HabitGroup? {
+ return cache.getHabitGroupByPosition(position)
+ }
+
override fun getItemCount(): Int {
- return cache.habitCount
+ return cache.itemCount
}
override fun getItemId(position: Int): Long {
- return getItem(position)!!.id!!
+ return cache.getIdByPosition(position)!!
}
/**
@@ -104,7 +126,7 @@ class HabitCardListAdapter @Inject constructor(
* @return true if selection is empty, false otherwise
*/
val isSelectionEmpty: Boolean
- get() = selected.isEmpty()
+ get() = selectedHabits.isEmpty() && selectedHabitGroups.isEmpty()
val isSortable: Boolean
get() = cache.primaryOrder == HabitList.Order.BY_POSITION
@@ -122,11 +144,18 @@ class HabitCardListAdapter @Inject constructor(
) {
if (listView == null) return
val habit = cache.getHabitByPosition(position)
- val score = cache.getScore(habit!!.id!!)
- val checkmarks = cache.getCheckmarks(habit.id!!)
- val notes = cache.getNotes(habit.id!!)
- val selected = selected.contains(habit)
- listView!!.bindCardView(holder, habit, score, checkmarks, notes, selected)
+ if (habit != null) {
+ val score = cache.getScore(habit.id!!)
+ val checkmarks = cache.getCheckmarks(habit.id!!)
+ val notes = cache.getNotes(habit.id!!)
+ val selected = selectedHabits.contains(habit)
+ listView!!.bindCardView(holder, habit, score, checkmarks, notes, selected)
+ } else {
+ val habitGroup = cache.getHabitGroupByPosition(position)
+ val score = cache.getScore(habitGroup!!.id!!)
+ val selected = selectedHabitGroups.contains(habitGroup)
+ listView!!.bindGroupCardView(holder, habitGroup, score, selected)
+ }
}
override fun onViewAttachedToWindow(holder: HabitCardViewHolder) {
@@ -141,8 +170,22 @@ class HabitCardListAdapter @Inject constructor(
parent: ViewGroup,
viewType: Int
): HabitCardViewHolder {
- val view = listView!!.createHabitCardView()
- return HabitCardViewHolder(view)
+ if (viewType == 0) {
+ val view = listView!!.createHabitCardView()
+ return HabitCardViewHolder(view, null)
+ } else {
+ val view = listView!!.createHabitGroupCardView()
+ return HabitCardViewHolder(null, view)
+ }
+ }
+
+ // function to override getItemViewType and return the type of the view. The view can either be a HabitCardView or a HabitGroupCardView
+ override fun getItemViewType(position: Int): Int {
+ return if (cache.getHabitByPosition(position) != null) {
+ 0
+ } else {
+ 1
+ }
}
/**
@@ -193,6 +236,10 @@ class HabitCardListAdapter @Inject constructor(
for (habit in selected) cache.remove(habit.id!!)
}
+ override fun performRemoveHabitGroup(selected: List) {
+ for (hgr in selected) cache.remove(hgr.id!!)
+ }
+
/**
* Changes the order of habits on the adapter.
*
@@ -249,11 +296,19 @@ class HabitCardListAdapter @Inject constructor(
*
* @param position position of the item to be toggled
*/
+ @SuppressLint("NotifyDataSetChanged")
fun toggleSelection(position: Int) {
- val h = getItem(position) ?: return
- val k = selected.indexOf(h)
- if (k < 0) selected.add(h) else selected.remove(h)
- notifyDataSetChanged()
+ val h = cache.getHabitByPosition(position)
+ val hgr = cache.getHabitGroupByPosition(position)
+ if (h != null) {
+ val k = selectedHabits.indexOf(h)
+ if (k < 0) selectedHabits.add(h) else selectedHabits.remove(h)
+ notifyDataSetChanged()
+ } else if (hgr != null) {
+ val k = selectedHabitGroups.indexOf(hgr)
+ if (k < 0) selectedHabitGroups.add(hgr) else selectedHabitGroups.remove(hgr)
+ notifyDataSetChanged()
+ }
}
init {
diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardListController.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardListController.kt
index a70805139..cf5055f90 100644
--- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardListController.kt
+++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardListController.kt
@@ -50,12 +50,22 @@ class HabitCardListController @Inject constructor(
if (from == to) return
cancelSelection()
- val habitFrom = adapter.getItem(from)
- val habitTo = adapter.getItem(to)
- if (habitFrom == null || habitTo == null) return
+ val habitFrom = adapter.getHabit(from)
+ val habitTo = adapter.getHabit(to)
+ if (habitFrom != null) {
+ if (habitTo != null) {
+ adapter.performReorder(from, to)
+ behavior.onReorderHabit(habitFrom, habitTo)
+ }
+ return
+ }
+ var hgrFrom = adapter.getHabitGroup(from)!!
+ if (hgrFrom.parent != null) hgrFrom = hgrFrom.parent!!
+ var hgrTo = adapter.getHabitGroup(to) ?: return
+ if (hgrTo.parent != null) hgrTo = hgrTo.parent!!
adapter.performReorder(from, to)
- behavior.onReorderHabit(habitFrom, habitTo)
+ behavior.onReorderHabitGroup(hgrFrom, hgrTo)
}
override fun onItemClick(position: Int) {
@@ -114,8 +124,15 @@ class HabitCardListController @Inject constructor(
*/
internal inner class NormalMode : Mode {
override fun onItemClick(position: Int) {
- val habit = adapter.getItem(position) ?: return
- behavior.onClickHabit(habit)
+ val habit = adapter.getHabit(position)
+ if (habit != null) {
+ behavior.onClickHabit(habit)
+ } else {
+ val hgr = adapter.getHabitGroup(position)
+ if (hgr != null) {
+ behavior.onClickHabitGroup(hgr)
+ }
+ }
}
override fun onItemLongClick(position: Int): Boolean {
diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardListView.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardListView.kt
index fd6df425f..a656b4d48 100644
--- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardListView.kt
+++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardListView.kt
@@ -36,6 +36,7 @@ import dagger.Lazy
import org.isoron.uhabits.R
import org.isoron.uhabits.activities.common.views.BundleSavedState
import org.isoron.uhabits.core.models.Habit
+import org.isoron.uhabits.core.models.HabitGroup
import org.isoron.uhabits.inject.ActivityContext
import javax.inject.Inject
@@ -62,8 +63,13 @@ class HabitCardListView(
set(value) {
field = value
attachedHolders
- .map { it.itemView as HabitCardView }
- .forEach { it.dataOffset = value }
+ .forEach {
+ if (it.itemView is HabitCardView) {
+ (it.itemView as HabitCardView).dataOffset = value
+ } else {
+ (it.itemView as HabitGroupCardView).dataOffset = value
+ }
+ }
}
private val attachedHolders = mutableListOf()
@@ -79,7 +85,11 @@ class HabitCardListView(
}
fun createHabitCardView(): HabitCardView {
- return cardViewFactory.create()
+ return cardViewFactory.createHabitCard()
+ }
+
+ fun createHabitGroupCardView(): HabitGroupCardView {
+ return cardViewFactory.createHabitGroupCard()
}
fun bindCardView(
@@ -110,8 +120,28 @@ class HabitCardListView(
return cardView
}
+ fun bindGroupCardView(
+ holder: HabitCardViewHolder,
+ habitGroup: HabitGroup,
+ score: Double,
+ selected: Boolean
+ ): View {
+ val cardView = holder.itemView as HabitGroupCardView
+ cardView.habitGroup = habitGroup
+ cardView.isSelected = selected
+ cardView.score = score
+
+ val detector = GestureDetector(context, CardViewGestureDetector(holder))
+ cardView.setOnTouchListener { _, ev ->
+ detector.onTouchEvent(ev)
+ true
+ }
+
+ return cardView
+ }
+
fun attachCardView(holder: HabitCardViewHolder) {
- (holder.itemView as HabitCardView).dataOffset = dataOffset
+ (holder.itemView as? HabitCardView)?.dataOffset = dataOffset
attachedHolders.add(holder)
}
diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardView.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardView.kt
index 84bd001fc..cac6cb1b2 100644
--- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardView.kt
+++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardView.kt
@@ -55,7 +55,8 @@ class HabitCardViewFactory
private val numberPanelFactory: NumberPanelViewFactory,
private val behavior: ListHabitsBehavior
) {
- fun create() = HabitCardView(context, checkmarkPanelFactory, numberPanelFactory, behavior)
+ fun createHabitCard() = HabitCardView(context, checkmarkPanelFactory, numberPanelFactory, behavior)
+ fun createHabitGroupCard() = HabitGroupCardView(context, behavior)
}
class HabitCardView(
@@ -265,6 +266,15 @@ class HabitCardView(
}
scoreRing.apply {
setColor(c)
+// if (h.isSubHabit()) {
+ val rightMargin = dp(8f).toInt()
+ val ringSize = dp(15f).toInt()
+ val leftMargin = if (h.isSubHabit() == true) dp(30f).toInt() else dp(8f).toInt()
+ layoutParams = LinearLayout.LayoutParams(ringSize, ringSize).apply {
+ setMargins(leftMargin, 0, rightMargin, 0)
+ gravity = Gravity.CENTER
+ }
+// }
}
checkmarkPanel.apply {
color = c
diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardViewHolder.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardViewHolder.kt
index e01159360..84fc26a2b 100644
--- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardViewHolder.kt
+++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardViewHolder.kt
@@ -21,4 +21,4 @@ package org.isoron.uhabits.activities.habits.list.views
import androidx.recyclerview.widget.RecyclerView
-class HabitCardViewHolder(itemView: HabitCardView) : RecyclerView.ViewHolder(itemView)
+class HabitCardViewHolder(itemView1: HabitCardView?, itemView2: HabitGroupCardView?) : RecyclerView.ViewHolder(itemView1 ?: itemView2!!)
diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitGroupCardView.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitGroupCardView.kt
new file mode 100644
index 000000000..6f5efd87a
--- /dev/null
+++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitGroupCardView.kt
@@ -0,0 +1,172 @@
+package org.isoron.uhabits.activities.habits.list.views
+
+import android.content.Context
+import android.graphics.Typeface
+import android.graphics.text.LineBreaker.BREAK_STRATEGY_BALANCED
+import android.os.Build
+import android.os.Build.VERSION.SDK_INT
+import android.os.Handler
+import android.os.Looper
+import android.text.TextUtils
+import android.view.Gravity
+import android.view.ViewGroup.LayoutParams.MATCH_PARENT
+import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
+import android.widget.FrameLayout
+import android.widget.LinearLayout
+import android.widget.TextView
+import org.isoron.platform.gui.toInt
+import org.isoron.uhabits.R
+import org.isoron.uhabits.activities.common.views.RingView
+import org.isoron.uhabits.core.models.HabitGroup
+import org.isoron.uhabits.core.models.ModelObservable
+import org.isoron.uhabits.core.ui.screens.habits.list.ListHabitsBehavior
+import org.isoron.uhabits.inject.ActivityContext
+import org.isoron.uhabits.utils.currentTheme
+import org.isoron.uhabits.utils.dp
+import org.isoron.uhabits.utils.sres
+
+class HabitGroupCardView(
+ @ActivityContext context: Context,
+ private val behavior: ListHabitsBehavior
+) : FrameLayout(context),
+ ModelObservable.Listener {
+
+ var dataOffset = 0
+
+ var habitGroup: HabitGroup? = null
+ set(newHabitGroup) {
+ if (isAttachedToWindow) {
+ field?.observable?.removeListener(this)
+ newHabitGroup?.observable?.addListener(this)
+ }
+ field = newHabitGroup
+ if (newHabitGroup != null) copyAttributesFrom(newHabitGroup)
+ addButtonView.habitGroup = newHabitGroup
+ collapseButtonView.habitGroup = newHabitGroup
+ }
+
+ var score
+ get() = scoreRing.getPercentage().toDouble()
+ set(value) {
+ scoreRing.setPercentage(value.toFloat())
+ scoreRing.setPrecision(1.0f / 16)
+ }
+
+ var addButtonView: AddButtonView
+ var collapseButtonView: CollapseButtonView
+ private var innerFrame: LinearLayout
+ private var label: TextView
+ private var scoreRing: RingView
+
+ private var currentToggleTaskId = 0
+
+ init {
+ scoreRing = RingView(context).apply {
+ val thickness = dp(3f)
+ val margin = dp(8f).toInt()
+ val ringSize = dp(15f).toInt()
+ layoutParams = LinearLayout.LayoutParams(ringSize, ringSize).apply {
+ setMargins(margin, 0, margin, 0)
+ gravity = Gravity.CENTER
+ }
+ setThickness(thickness)
+ }
+
+ label = TextView(context).apply {
+ maxLines = 2
+ ellipsize = TextUtils.TruncateAt.END
+ layoutParams = LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f)
+ if (SDK_INT >= Build.VERSION_CODES.Q) {
+ breakStrategy = BREAK_STRATEGY_BALANCED
+ }
+ setTypeface(typeface, Typeface.BOLD)
+ }
+
+ addButtonView = AddButtonView(context, habitGroup)
+ collapseButtonView = CollapseButtonView(context, habitGroup)
+
+ innerFrame = LinearLayout(context).apply {
+ gravity = Gravity.CENTER_VERTICAL
+ orientation = LinearLayout.HORIZONTAL
+ layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
+ elevation = dp(1f)
+
+ addView(scoreRing)
+ addView(label)
+ addView(addButtonView)
+ addView(collapseButtonView)
+
+ setOnTouchListener { v, event ->
+ v.background.setHotspot(event.x, event.y)
+ false
+ }
+ }
+
+ clipToPadding = false
+ layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT)
+ val margin = dp(3f).toInt()
+ setPadding(margin, 0, margin, margin)
+ addView(innerFrame)
+ }
+
+ override fun onModelChange() {
+ Handler(Looper.getMainLooper()).post {
+ habitGroup?.let { copyAttributesFrom(it) }
+ }
+ }
+
+ override fun setSelected(isSelected: Boolean) {
+ super.setSelected(isSelected)
+ updateBackground(isSelected)
+ }
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ habitGroup?.observable?.addListener(this)
+ }
+
+ override fun onDetachedFromWindow() {
+ habitGroup?.observable?.removeListener(this)
+ super.onDetachedFromWindow()
+ }
+
+ private fun copyAttributesFrom(hgr: HabitGroup) {
+ fun getActiveColor(hgr: HabitGroup): Int {
+ return when (hgr.isArchived) {
+ true -> sres.getColor(R.attr.contrast60)
+ false -> currentTheme().color(hgr.color).toInt()
+ }
+ }
+
+ val c = getActiveColor(hgr)
+ label.apply {
+ text = hgr.name
+ setTextColor(c)
+ }
+ scoreRing.apply {
+ setColor(c)
+ }
+
+ collapseButtonView.collapsed = hgr.collapsed
+
+ if (collapseButtonView.collapsed) {
+ addButtonView.visibility = GONE
+ } else {
+ addButtonView.visibility = VISIBLE
+ }
+ }
+
+ private fun updateBackground(isSelected: Boolean) {
+ val background = when (isSelected) {
+ true -> R.drawable.selected_box
+ false -> R.drawable.ripple
+ }
+ innerFrame.setBackgroundResource(background)
+ }
+
+ companion object {
+ fun (() -> Unit).delay(delayInMillis: Long) {
+ Handler(Looper.getMainLooper()).postDelayed(this, delayInMillis)
+ }
+ }
+}
diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/ShowHabitActivity.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/ShowHabitActivity.kt
index a7d07db53..a3c197415 100644
--- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/ShowHabitActivity.kt
+++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/ShowHabitActivity.kt
@@ -18,7 +18,6 @@
*/
package org.isoron.uhabits.activities.habits.show
-import android.content.ContentUris
import android.os.Bundle
import android.view.HapticFeedbackConstants
import android.view.Menu
@@ -75,8 +74,15 @@ class ShowHabitActivity : AppCompatActivity(), CommandRunner.Listener {
super.onCreate(savedInstanceState)
val appComponent = (applicationContext as HabitsApplication).component
- val habitList = appComponent.habitList
- habit = habitList.getById(ContentUris.parseId(intent.data!!))!!
+ val habitGroupList = appComponent.habitGroupList
+ val groupId = intent.getLongExtra("groupId", -1L)
+ val habitList = if (groupId > 0) {
+ habitGroupList.getById(groupId)!!.habitList
+ } else {
+ appComponent.habitList
+ }
+ val id = intent.getLongExtra("habitId", -1L)
+ habit = habitList.getById(id)!!
preferences = appComponent.preferences
commandRunner = appComponent.commandRunner
widgetUpdater = appComponent.widgetUpdater
diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/ShowHabitGroupActivity.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/ShowHabitGroupActivity.kt
new file mode 100644
index 000000000..d204f7a48
--- /dev/null
+++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/ShowHabitGroupActivity.kt
@@ -0,0 +1,148 @@
+package org.isoron.uhabits.activities.habits.show
+
+import android.content.ContentUris
+import android.os.Bundle
+import android.view.Menu
+import android.view.MenuItem
+import androidx.appcompat.app.AppCompatActivity
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import org.isoron.uhabits.HabitsApplication
+import org.isoron.uhabits.R
+import org.isoron.uhabits.activities.AndroidThemeSwitcher
+import org.isoron.uhabits.activities.common.dialogs.ConfirmDeleteDialog
+import org.isoron.uhabits.core.commands.Command
+import org.isoron.uhabits.core.commands.CommandRunner
+import org.isoron.uhabits.core.models.HabitGroup
+import org.isoron.uhabits.core.preferences.Preferences
+import org.isoron.uhabits.core.ui.callbacks.OnConfirmedCallback
+import org.isoron.uhabits.core.ui.screens.habits.show.ShowHabitGroupMenuPresenter
+import org.isoron.uhabits.core.ui.screens.habits.show.ShowHabitGroupPresenter
+import org.isoron.uhabits.intents.IntentFactory
+import org.isoron.uhabits.utils.dismissCurrentAndShow
+import org.isoron.uhabits.utils.dismissCurrentDialog
+import org.isoron.uhabits.utils.showMessage
+import org.isoron.uhabits.utils.showSendFileScreen
+import org.isoron.uhabits.widgets.WidgetUpdater
+
+class ShowHabitGroupActivity : AppCompatActivity(), CommandRunner.Listener {
+
+ private lateinit var commandRunner: CommandRunner
+ private lateinit var menu: ShowHabitGroupMenu
+ private lateinit var view: ShowHabitGroupView
+ private lateinit var habitGroup: HabitGroup
+ private lateinit var preferences: Preferences
+ private lateinit var themeSwitcher: AndroidThemeSwitcher
+ private lateinit var widgetUpdater: WidgetUpdater
+
+ private val scope = CoroutineScope(Dispatchers.Main)
+ private lateinit var presenter: ShowHabitGroupPresenter
+ private val screen = Screen()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val appComponent = (applicationContext as HabitsApplication).component
+ val habitGroupList = appComponent.habitGroupList
+ habitGroup = habitGroupList.getById(ContentUris.parseId(intent.data!!))!!
+ preferences = appComponent.preferences
+ commandRunner = appComponent.commandRunner
+ widgetUpdater = appComponent.widgetUpdater
+
+ themeSwitcher = AndroidThemeSwitcher(this, preferences)
+ themeSwitcher.apply()
+
+ presenter = ShowHabitGroupPresenter(
+ commandRunner = commandRunner,
+ habitGroup = habitGroup,
+ preferences = preferences,
+ screen = screen
+ )
+
+ view = ShowHabitGroupView(this)
+
+ val menuPresenter = ShowHabitGroupMenuPresenter(
+ commandRunner = commandRunner,
+ habitGroup = habitGroup,
+ habitGroupList = habitGroupList,
+ screen = screen
+ )
+
+ menu = ShowHabitGroupMenu(
+ activity = this,
+ presenter = menuPresenter,
+ preferences = preferences
+ )
+
+ view.setListener(presenter)
+ setContentView(view)
+ }
+
+ override fun onCreateOptionsMenu(m: Menu): Boolean {
+ return menu.onCreateOptionsMenu(m)
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ return menu.onOptionsItemSelected(item)
+ }
+
+ override fun onResume() {
+ super.onResume()
+ commandRunner.addListener(this)
+ screen.refresh()
+ }
+
+ override fun onPause() {
+ dismissCurrentDialog()
+ commandRunner.removeListener(this)
+ super.onPause()
+ }
+
+ override fun onCommandFinished(command: Command) {
+ screen.refresh()
+ }
+
+ inner class Screen : ShowHabitGroupMenuPresenter.Screen, ShowHabitGroupPresenter.Screen {
+ override fun updateWidgets() {
+ widgetUpdater.updateWidgets()
+ }
+
+ override fun refresh() {
+ scope.launch {
+ view.setState(
+ ShowHabitGroupPresenter.buildState(
+ habitGroup = habitGroup,
+ preferences = preferences,
+ theme = themeSwitcher.currentTheme
+ )
+ )
+ }
+ }
+
+ override fun showEditHabitGroupScreen(habitGroup: HabitGroup) {
+ startActivity(IntentFactory().startEditGroupActivity(this@ShowHabitGroupActivity, habitGroup))
+ }
+
+ override fun showMessage(m: ShowHabitGroupMenuPresenter.Message?) {
+ when (m) {
+ ShowHabitGroupMenuPresenter.Message.COULD_NOT_EXPORT -> {
+ showMessage(resources.getString(R.string.could_not_export))
+ }
+ else -> {}
+ }
+ }
+
+ override fun showSendFileScreen(filename: String) {
+ this@ShowHabitGroupActivity.showSendFileScreen(filename)
+ }
+
+ override fun showDeleteConfirmationScreen(callback: OnConfirmedCallback) {
+ ConfirmDeleteDialog(this@ShowHabitGroupActivity, callback, 1).dismissCurrentAndShow()
+ }
+
+ override fun close() {
+ this@ShowHabitGroupActivity.finish()
+ }
+ }
+}
diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/ShowHabitGroupMenu.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/ShowHabitGroupMenu.kt
new file mode 100644
index 000000000..1dc4bbd03
--- /dev/null
+++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/ShowHabitGroupMenu.kt
@@ -0,0 +1,32 @@
+package org.isoron.uhabits.activities.habits.show
+
+import android.view.Menu
+import android.view.MenuItem
+import org.isoron.uhabits.R
+import org.isoron.uhabits.core.preferences.Preferences
+import org.isoron.uhabits.core.ui.screens.habits.show.ShowHabitGroupMenuPresenter
+
+class ShowHabitGroupMenu(
+ val activity: ShowHabitGroupActivity,
+ val presenter: ShowHabitGroupMenuPresenter,
+ val preferences: Preferences
+) {
+ fun onCreateOptionsMenu(menu: Menu): Boolean {
+ activity.menuInflater.inflate(R.menu.show_habit_group, menu)
+ return true
+ }
+
+ fun onOptionsItemSelected(item: MenuItem): Boolean {
+ when (item.itemId) {
+ R.id.action_edit_habit_group -> {
+ presenter.onEditHabitGroup()
+ return true
+ }
+ R.id.action_delete -> {
+ presenter.onDeleteHabitGroup()
+ return true
+ }
+ }
+ return false
+ }
+}
diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/ShowHabitGroupView.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/ShowHabitGroupView.kt
new file mode 100644
index 000000000..1a51347b6
--- /dev/null
+++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/ShowHabitGroupView.kt
@@ -0,0 +1,56 @@
+package org.isoron.uhabits.activities.habits.show
+
+import android.content.Context
+import android.view.LayoutInflater
+import android.widget.FrameLayout
+import org.isoron.uhabits.core.ui.screens.habits.show.ShowHabitGroupPresenter
+import org.isoron.uhabits.core.ui.screens.habits.show.ShowHabitGroupState
+import org.isoron.uhabits.databinding.ShowHabitGroupBinding
+import org.isoron.uhabits.utils.setupToolbar
+
+class ShowHabitGroupView(context: Context) : FrameLayout(context) {
+ private val binding = ShowHabitGroupBinding.inflate(LayoutInflater.from(context))
+
+ init {
+ addView(binding.root)
+ }
+
+ fun setState(data: ShowHabitGroupState) {
+ setupToolbar(
+ binding.toolbar,
+ title = data.title,
+ color = data.color,
+ theme = data.theme
+ )
+ binding.subtitleCard.setState(data.subtitle)
+ binding.overviewCard.setState(data.overview)
+ binding.notesCard.setState(data.notes)
+ binding.targetCard.setState(data.target)
+ binding.streakCard.setState(data.streaks)
+ binding.scoreCard.setState(data.scores)
+ binding.barCard.setState(data.bar)
+ binding.frequencyCard.setState(data.frequency)
+ if (!data.isBoolean) {
+ binding.streakCard.visibility = GONE
+ if (!data.isNumerical) {
+ binding.barCard.visibility = GONE
+ }
+ }
+ if (!data.isNumerical) {
+ binding.targetCard.visibility = GONE
+ }
+ if (data.isEmpty) {
+ binding.targetCard.visibility = GONE
+ binding.barCard.visibility = GONE
+ binding.streakCard.visibility = GONE
+ binding.overviewCard.visibility = GONE
+ binding.scoreCard.visibility = GONE
+ binding.frequencyCard.visibility = GONE
+ binding.noSubHabitsCard.visibility = VISIBLE
+ }
+ }
+
+ fun setListener(presenter: ShowHabitGroupPresenter) {
+ binding.scoreCard.setListener(presenter.scoreCardPresenter)
+ }
+}
diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsApplicationComponent.kt b/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsApplicationComponent.kt
index da7f957f3..c3dc68e82 100644
--- a/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsApplicationComponent.kt
+++ b/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsApplicationComponent.kt
@@ -24,6 +24,7 @@ import org.isoron.uhabits.core.AppScope
import org.isoron.uhabits.core.commands.CommandRunner
import org.isoron.uhabits.core.io.GenericImporter
import org.isoron.uhabits.core.io.Logging
+import org.isoron.uhabits.core.models.HabitGroupList
import org.isoron.uhabits.core.models.HabitList
import org.isoron.uhabits.core.models.ModelFactory
import org.isoron.uhabits.core.preferences.Preferences
@@ -50,6 +51,7 @@ interface HabitsApplicationComponent {
val genericImporter: GenericImporter
val habitCardListCache: HabitCardListCache
val habitList: HabitList
+ val habitGroupList: HabitGroupList
val intentFactory: IntentFactory
val intentParser: IntentParser
val logging: Logging
diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsModule.kt b/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsModule.kt
index c7b6843d0..fd687604e 100644
--- a/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsModule.kt
+++ b/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsModule.kt
@@ -26,9 +26,11 @@ import org.isoron.uhabits.core.commands.CommandRunner
import org.isoron.uhabits.core.database.Database
import org.isoron.uhabits.core.database.DatabaseOpener
import org.isoron.uhabits.core.io.Logging
+import org.isoron.uhabits.core.models.HabitGroupList
import org.isoron.uhabits.core.models.HabitList
import org.isoron.uhabits.core.models.ModelFactory
import org.isoron.uhabits.core.models.sqlite.SQLModelFactory
+import org.isoron.uhabits.core.models.sqlite.SQLiteHabitGroupList
import org.isoron.uhabits.core.models.sqlite.SQLiteHabitList
import org.isoron.uhabits.core.preferences.Preferences
import org.isoron.uhabits.core.preferences.WidgetPreferences
@@ -61,9 +63,10 @@ class HabitsModule(dbFile: File) {
sys: IntentScheduler,
commandRunner: CommandRunner,
habitList: HabitList,
+ habitGroupList: HabitGroupList,
widgetPreferences: WidgetPreferences
): ReminderScheduler {
- return ReminderScheduler(commandRunner, habitList, sys, widgetPreferences)
+ return ReminderScheduler(commandRunner, habitList, habitGroupList, sys, widgetPreferences)
}
@Provides
@@ -97,6 +100,12 @@ class HabitsModule(dbFile: File) {
return list
}
+ @Provides
+ @AppScope
+ fun getHabitGroupList(list: SQLiteHabitGroupList): HabitGroupList {
+ return list
+ }
+
@Provides
@AppScope
fun getDatabaseOpener(opener: AndroidDatabaseOpener): DatabaseOpener {
diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/intents/IntentFactory.kt b/uhabits-android/src/main/java/org/isoron/uhabits/intents/IntentFactory.kt
index 71b2938e7..2515d5554 100644
--- a/uhabits-android/src/main/java/org/isoron/uhabits/intents/IntentFactory.kt
+++ b/uhabits-android/src/main/java/org/isoron/uhabits/intents/IntentFactory.kt
@@ -25,10 +25,14 @@ import android.net.Uri
import org.isoron.uhabits.R
import org.isoron.uhabits.activities.about.AboutActivity
import org.isoron.uhabits.activities.habits.edit.EditHabitActivity
+import org.isoron.uhabits.activities.habits.edit.EditHabitGroupActivity
+import org.isoron.uhabits.activities.habits.list.HabitGroupPickerDialog
import org.isoron.uhabits.activities.habits.show.ShowHabitActivity
+import org.isoron.uhabits.activities.habits.show.ShowHabitGroupActivity
import org.isoron.uhabits.activities.intro.IntroActivity
import org.isoron.uhabits.activities.settings.SettingsActivity
import org.isoron.uhabits.core.models.Habit
+import org.isoron.uhabits.core.models.HabitGroup
import javax.inject.Inject
class IntentFactory
@@ -60,9 +64,16 @@ class IntentFactory
fun startSettingsActivity(context: Context) =
Intent(context, SettingsActivity::class.java)
- fun startShowHabitActivity(context: Context, habit: Habit) =
- Intent(context, ShowHabitActivity::class.java).apply {
- data = Uri.parse(habit.uriString)
+ fun startShowHabitActivity(context: Context, habit: Habit): Intent {
+ val intent = Intent(context, ShowHabitActivity::class.java)
+ intent.putExtra("habitId", habit.id)
+ intent.putExtra("groupId", habit.groupId)
+ return intent
+ }
+
+ fun startShowHabitGroupActivity(context: Context, habitGroup: HabitGroup) =
+ Intent(context, ShowHabitGroupActivity::class.java).apply {
+ data = Uri.parse(habitGroup.uriString)
}
fun viewFAQ(context: Context) =
@@ -92,12 +103,33 @@ class IntentFactory
val intent = startEditActivity(context)
intent.putExtra("habitId", habit.id)
intent.putExtra("habitType", habit.type)
+ if (habit.groupId != null) intent.putExtra("groupId", habit.groupId)
return intent
}
- fun startEditActivity(context: Context, habitType: Int): Intent {
+ fun startEditActivity(context: Context, habitType: Int, groupId: Long?): Intent {
val intent = startEditActivity(context)
intent.putExtra("habitType", habitType)
+ if (groupId != null) {
+ intent.putExtra("groupId", groupId)
+ }
+ return intent
+ }
+
+ fun startEditGroupActivity(context: Context): Intent {
+ return Intent(context, EditHabitGroupActivity::class.java)
+ }
+
+ fun startEditGroupActivity(context: Context, habitGroup: HabitGroup): Intent {
+ val intent = startEditGroupActivity(context)
+ intent.putExtra("habitGroupId", habitGroup.id)
+ return intent
+ }
+
+ fun startHabitGroupPickerActivity(context: Context, selected: List): Intent {
+ val intent = Intent(context, HabitGroupPickerDialog::class.java)
+ val ids = selected.map { it.id!! }.toLongArray()
+ intent.putExtra("selected", ids)
return intent
}
}
diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/intents/IntentParser.kt b/uhabits-android/src/main/java/org/isoron/uhabits/intents/IntentParser.kt
index c3981baf2..fd1fc3206 100644
--- a/uhabits-android/src/main/java/org/isoron/uhabits/intents/IntentParser.kt
+++ b/uhabits-android/src/main/java/org/isoron/uhabits/intents/IntentParser.kt
@@ -24,6 +24,7 @@ import android.content.Intent
import android.net.Uri
import org.isoron.uhabits.core.AppScope
import org.isoron.uhabits.core.models.Habit
+import org.isoron.uhabits.core.models.HabitGroupList
import org.isoron.uhabits.core.models.HabitList
import org.isoron.uhabits.core.models.Timestamp
import org.isoron.uhabits.core.utils.DateUtils
@@ -31,7 +32,10 @@ import javax.inject.Inject
@AppScope
class IntentParser
-@Inject constructor(private val habits: HabitList) {
+@Inject constructor(
+ private val habits: HabitList,
+ private val habitGroups: HabitGroupList
+) {
fun parseCheckmarkIntent(intent: Intent): CheckmarkIntentData {
val uri = intent.data ?: throw IllegalArgumentException("uri is null")
@@ -45,6 +49,7 @@ class IntentParser
private fun parseHabit(uri: Uri): Habit {
return habits.getById(parseId(uri))
+ ?: habitGroups.getHabitByID(parseId(uri))
?: throw IllegalArgumentException("habit not found")
}
diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/intents/IntentScheduler.kt b/uhabits-android/src/main/java/org/isoron/uhabits/intents/IntentScheduler.kt
index 047474d67..55791ad1e 100644
--- a/uhabits-android/src/main/java/org/isoron/uhabits/intents/IntentScheduler.kt
+++ b/uhabits-android/src/main/java/org/isoron/uhabits/intents/IntentScheduler.kt
@@ -29,6 +29,7 @@ import android.os.Build
import android.util.Log
import org.isoron.uhabits.core.AppScope
import org.isoron.uhabits.core.models.Habit
+import org.isoron.uhabits.core.models.HabitGroup
import org.isoron.uhabits.core.reminders.ReminderScheduler.SchedulerResult
import org.isoron.uhabits.core.reminders.ReminderScheduler.SystemScheduler
import org.isoron.uhabits.core.utils.DateFormats
@@ -75,6 +76,16 @@ class IntentScheduler
return schedule(reminderTime, intent, RTC_WAKEUP)
}
+ override fun scheduleShowReminder(
+ reminderTime: Long,
+ habitGroup: HabitGroup,
+ timestamp: Long
+ ): SchedulerResult {
+ val intent = pendingIntents.showReminder(habitGroup, reminderTime, timestamp)
+ logReminderScheduled(habitGroup, reminderTime)
+ return schedule(reminderTime, intent, RTC_WAKEUP)
+ }
+
override fun scheduleWidgetUpdate(updateTime: Long): SchedulerResult {
val intent = pendingIntents.updateWidgets()
return schedule(updateTime, intent, RTC)
@@ -94,4 +105,15 @@ class IntentScheduler
String.format("Setting alarm (%s): %s", time, name)
)
}
+
+ private fun logReminderScheduled(habitGroup: HabitGroup, reminderTime: Long) {
+ val min = min(5, habitGroup.name.length)
+ val name = habitGroup.name.substring(0, min)
+ val df = DateFormats.getBackupDateFormat()
+ val time = df.format(Date(reminderTime))
+ Log.i(
+ "ReminderHelper",
+ String.format("Setting alarm (%s): %s", time, name)
+ )
+ }
}
diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/intents/PendingIntentFactory.kt b/uhabits-android/src/main/java/org/isoron/uhabits/intents/PendingIntentFactory.kt
index 038cfe3a4..45a70cd14 100644
--- a/uhabits-android/src/main/java/org/isoron/uhabits/intents/PendingIntentFactory.kt
+++ b/uhabits-android/src/main/java/org/isoron/uhabits/intents/PendingIntentFactory.kt
@@ -31,8 +31,10 @@ import android.net.Uri
import android.os.Build
import org.isoron.uhabits.activities.habits.list.ListHabitsActivity
import org.isoron.uhabits.activities.habits.show.ShowHabitActivity
+import org.isoron.uhabits.activities.habits.show.ShowHabitGroupActivity
import org.isoron.uhabits.core.AppScope
import org.isoron.uhabits.core.models.Habit
+import org.isoron.uhabits.core.models.HabitGroup
import org.isoron.uhabits.core.models.Timestamp
import org.isoron.uhabits.inject.AppContext
import org.isoron.uhabits.receivers.ReminderReceiver
@@ -69,6 +71,17 @@ class PendingIntentFactory
FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT
)
+ fun dismissNotification(habitGroup: HabitGroup): PendingIntent =
+ getBroadcast(
+ context,
+ 0,
+ Intent(context, ReminderReceiver::class.java).apply {
+ action = WidgetReceiver.ACTION_DISMISS_REMINDER
+ data = Uri.parse(habitGroup.uriString)
+ },
+ FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT
+ )
+
fun removeRepetition(habit: Habit, timestamp: Timestamp?): PendingIntent =
getBroadcast(
context,
@@ -92,6 +105,17 @@ class PendingIntentFactory
)
.getPendingIntent(0, FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT)!!
+ fun showHabitGroup(habitGroup: HabitGroup): PendingIntent =
+ androidx.core.app.TaskStackBuilder
+ .create(context)
+ .addNextIntentWithParentStack(
+ intentFactory.startShowHabitGroupActivity(
+ context,
+ habitGroup
+ )
+ )
+ .getPendingIntent(0, FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT)!!
+
fun showHabitTemplate(): PendingIntent {
return getActivity(
context,
@@ -101,6 +125,15 @@ class PendingIntentFactory
)
}
+ fun showHabitGroupTemplate(): PendingIntent {
+ return getActivity(
+ context,
+ 0,
+ Intent(context, ShowHabitGroupActivity::class.java),
+ getIntentTemplateFlags()
+ )
+ }
+
fun showHabitFillIn(habit: Habit) =
Intent().apply {
data = Uri.parse(habit.uriString)
@@ -123,6 +156,23 @@ class PendingIntentFactory
FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT
)
+ fun showReminder(
+ habitGroup: HabitGroup,
+ reminderTime: Long?,
+ timestamp: Long
+ ): PendingIntent =
+ getBroadcast(
+ context,
+ (habitGroup.id!! % Integer.MAX_VALUE).toInt() + 1,
+ Intent(context, ReminderReceiver::class.java).apply {
+ action = ReminderReceiver.ACTION_SHOW_REMINDER
+ data = Uri.parse(habitGroup.uriString)
+ putExtra("timestamp", timestamp)
+ putExtra("reminderTime", reminderTime)
+ },
+ FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT
+ )
+
fun snoozeNotification(habit: Habit): PendingIntent =
getBroadcast(
context,
@@ -134,6 +184,17 @@ class PendingIntentFactory
FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT
)
+ fun snoozeNotification(habitGroup: HabitGroup): PendingIntent =
+ getBroadcast(
+ context,
+ 0,
+ Intent(context, ReminderReceiver::class.java).apply {
+ data = Uri.parse(habitGroup.uriString)
+ action = ReminderReceiver.ACTION_SNOOZE_REMINDER
+ },
+ FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT
+ )
+
fun toggleCheckmark(habit: Habit, timestamp: Long?): PendingIntent =
getBroadcast(
context,
@@ -185,6 +246,26 @@ class PendingIntentFactory
putExtra("timestamp", timestamp.unixTime)
}
+ fun showHabitList(): PendingIntent {
+ return getActivity(
+ context,
+ 1,
+ Intent(context, ListHabitsActivity::class.java),
+ getIntentTemplateFlags()
+ )
+ }
+
+ fun showHabitListWithNotificationClear(id: Long): PendingIntent {
+ return getActivity(
+ context,
+ 0,
+ Intent(context, ListHabitsActivity::class.java).apply {
+ putExtra("CLEAR_NOTIFICATION_HABIT_ID", id)
+ },
+ getIntentTemplateFlags()
+ )
+ }
+
private fun getIntentTemplateFlags(): Int {
var flags = 0
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/notifications/AndroidNotificationTray.kt b/uhabits-android/src/main/java/org/isoron/uhabits/notifications/AndroidNotificationTray.kt
index a63982358..bb28b5ad3 100644
--- a/uhabits-android/src/main/java/org/isoron/uhabits/notifications/AndroidNotificationTray.kt
+++ b/uhabits-android/src/main/java/org/isoron/uhabits/notifications/AndroidNotificationTray.kt
@@ -35,6 +35,7 @@ import androidx.core.app.NotificationManagerCompat
import org.isoron.uhabits.R
import org.isoron.uhabits.core.AppScope
import org.isoron.uhabits.core.models.Habit
+import org.isoron.uhabits.core.models.HabitGroup
import org.isoron.uhabits.core.models.Timestamp
import org.isoron.uhabits.core.preferences.Preferences
import org.isoron.uhabits.core.ui.NotificationTray
@@ -90,6 +91,34 @@ class AndroidNotificationTray
active.add(notificationId)
}
+ override fun showNotification(
+ habitGroup: HabitGroup,
+ notificationId: Int,
+ timestamp: Timestamp,
+ reminderTime: Long
+ ) {
+ val notificationManager = NotificationManagerCompat.from(context)
+ val notification = buildNotification(habitGroup, reminderTime, timestamp)
+ createAndroidNotificationChannel(context)
+ try {
+ notificationManager.notify(notificationId, notification)
+ } catch (e: RuntimeException) {
+ // Some Xiaomi phones produce a RuntimeException if custom notification sounds are used.
+ Log.i(
+ "AndroidNotificationTray",
+ "Failed to show notification. Retrying without sound."
+ )
+ val n = buildNotification(
+ habitGroup,
+ reminderTime,
+ timestamp,
+ disableSound = true
+ )
+ notificationManager.notify(notificationId, n)
+ }
+ active.add(notificationId)
+ }
+
fun buildNotification(
habit: Habit,
reminderTime: Long,
@@ -163,6 +192,58 @@ class AndroidNotificationTray
return builder.build()
}
+ fun buildNotification(
+ habitGroup: HabitGroup,
+ reminderTime: Long,
+ timestamp: Timestamp,
+ disableSound: Boolean = false
+ ): Notification {
+ val enterAction = Action(
+ R.drawable.ic_action_check,
+ context.getString(R.string.enter),
+ pendingIntents.showHabitListWithNotificationClear(habitGroup.id!!)
+ )
+
+ val wearableBg = decodeResource(context.resources, R.drawable.stripe)
+
+ // Even though the set of actions is the same on the phone and
+ // on the watch, Pebble requires us to add them to the
+ // WearableExtender.
+ val wearableExtender = WearableExtender().setBackground(wearableBg)
+
+ val defaultText = context.getString(R.string.default_reminder_question)
+ val builder = Builder(context, REMINDERS_CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_notification)
+ .setContentTitle(habitGroup.name)
+ .setContentText(if (habitGroup.question.isBlank()) defaultText else habitGroup.question)
+ .setContentIntent(pendingIntents.showHabitGroup(habitGroup))
+ .setDeleteIntent(pendingIntents.dismissNotification(habitGroup))
+ .setSound(null)
+ .setWhen(reminderTime)
+ .setShowWhen(true)
+ .setOngoing(preferences.shouldMakeNotificationsSticky())
+
+ wearableExtender.addAction(enterAction)
+ builder.addAction(enterAction)
+
+ if (!disableSound) {
+ builder.setSound(ringtoneManager.getURI())
+ }
+
+ if (SDK_INT < Build.VERSION_CODES.S) {
+ val snoozeAction = Action(
+ R.drawable.ic_action_snooze,
+ context.getString(R.string.snooze),
+ pendingIntents.snoozeNotification(habitGroup)
+ )
+ wearableExtender.addAction(snoozeAction)
+ builder.addAction(snoozeAction)
+ }
+
+ builder.extend(wearableExtender)
+ return builder.build()
+ }
+
companion object {
private const val REMINDERS_CHANNEL_ID = "REMINDERS"
fun createAndroidNotificationChannel(context: Context) {
diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/notifications/SnoozeDelayPickerActivity.kt b/uhabits-android/src/main/java/org/isoron/uhabits/notifications/SnoozeDelayPickerActivity.kt
index f7e532984..23a9ab9db 100644
--- a/uhabits-android/src/main/java/org/isoron/uhabits/notifications/SnoozeDelayPickerActivity.kt
+++ b/uhabits-android/src/main/java/org/isoron/uhabits/notifications/SnoozeDelayPickerActivity.kt
@@ -33,6 +33,7 @@ import org.isoron.uhabits.HabitsApplication
import org.isoron.uhabits.R
import org.isoron.uhabits.activities.AndroidThemeSwitcher
import org.isoron.uhabits.core.models.Habit
+import org.isoron.uhabits.core.models.HabitGroup
import org.isoron.uhabits.core.ui.views.DarkTheme
import org.isoron.uhabits.core.ui.views.LightTheme
import org.isoron.uhabits.receivers.ReminderController
@@ -41,6 +42,7 @@ import java.util.Calendar
class SnoozeDelayPickerActivity : FragmentActivity(), OnItemClickListener {
private var habit: Habit? = null
+ private var habitGroup: HabitGroup? = null
private var reminderController: ReminderController? = null
private var dialog: AlertDialog? = null
private var androidColor: Int = 0
@@ -58,10 +60,13 @@ class SnoozeDelayPickerActivity : FragmentActivity(), OnItemClickListener {
if (data == null) {
finish()
} else {
- habit = appComponent.habitList.getById(ContentUris.parseId(data))
+ val id = ContentUris.parseId(data)
+ habit = appComponent.habitList.getById(id) ?: appComponent.habitGroupList.getHabitByID(id)
+ habitGroup = appComponent.habitGroupList.getById(id)
}
- if (habit == null) finish()
- androidColor = themeSwitcher.currentTheme.color(habit!!.color).toInt()
+ if (habit == null && habitGroup == null) finish()
+ val color = habit?.color ?: habitGroup!!.color
+ androidColor = themeSwitcher.currentTheme.color(color).toInt()
reminderController = appComponent.reminderController
dialog = AlertDialog.Builder(this)
.setTitle(R.string.select_snooze_delay)
@@ -87,7 +92,11 @@ class SnoozeDelayPickerActivity : FragmentActivity(), OnItemClickListener {
val calendar = Calendar.getInstance()
val dialog = TimePickerDialog.newInstance(
{ view: RadialPickerLayout?, hour: Int, minute: Int ->
- reminderController!!.onSnoozeTimePicked(habit, hour, minute)
+ if (habit != null) {
+ reminderController!!.onSnoozeTimePicked(habit, hour, minute)
+ } else {
+ reminderController!!.onSnoozeTimePicked(habitGroup, hour, minute)
+ }
finish()
},
calendar[Calendar.HOUR_OF_DAY],
@@ -101,7 +110,11 @@ class SnoozeDelayPickerActivity : FragmentActivity(), OnItemClickListener {
override fun onItemClick(parent: AdapterView<*>?, view: View, position: Int, id: Long) {
val snoozeValues = resources.getIntArray(R.array.snooze_picker_values)
if (snoozeValues[position] >= 0) {
- reminderController!!.onSnoozeDelayPicked(habit!!, snoozeValues[position])
+ if (habit != null) {
+ reminderController!!.onSnoozeDelayPicked(habit!!, snoozeValues[position])
+ } else {
+ reminderController!!.onSnoozeDelayPicked(habitGroup!!, snoozeValues[position])
+ }
finish()
} else {
showTimePicker()
diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/receivers/ReminderController.kt b/uhabits-android/src/main/java/org/isoron/uhabits/receivers/ReminderController.kt
index 4937dbc74..e3fd3ee6a 100644
--- a/uhabits-android/src/main/java/org/isoron/uhabits/receivers/ReminderController.kt
+++ b/uhabits-android/src/main/java/org/isoron/uhabits/receivers/ReminderController.kt
@@ -23,6 +23,7 @@ import android.content.Intent
import android.net.Uri
import org.isoron.uhabits.core.AppScope
import org.isoron.uhabits.core.models.Habit
+import org.isoron.uhabits.core.models.HabitGroup
import org.isoron.uhabits.core.models.Timestamp
import org.isoron.uhabits.core.preferences.Preferences
import org.isoron.uhabits.core.reminders.ReminderScheduler
@@ -50,21 +51,45 @@ class ReminderController @Inject constructor(
reminderScheduler.scheduleAll()
}
+ fun onShowReminder(
+ habitGroup: HabitGroup,
+ timestamp: Timestamp,
+ reminderTime: Long
+ ) {
+ notificationTray.show(habitGroup, timestamp, reminderTime)
+ reminderScheduler.scheduleAll()
+ }
+
fun onSnoozePressed(habit: Habit, context: Context) {
showSnoozeDelayPicker(habit, context)
}
+ fun onSnoozePressed(habitGroup: HabitGroup, context: Context) {
+ showSnoozeDelayPicker(habitGroup, context)
+ }
+
fun onSnoozeDelayPicked(habit: Habit, delayInMinutes: Int) {
reminderScheduler.snoozeReminder(habit, delayInMinutes.toLong())
notificationTray.cancel(habit)
}
+ fun onSnoozeDelayPicked(habitGroup: HabitGroup, delayInMinutes: Int) {
+ reminderScheduler.snoozeReminder(habitGroup, delayInMinutes.toLong())
+ notificationTray.cancel(habitGroup)
+ }
+
fun onSnoozeTimePicked(habit: Habit?, hour: Int, minute: Int) {
val time: Long = getUpcomingTimeInMillis(hour, minute)
reminderScheduler.scheduleAtTime(habit!!, time)
notificationTray.cancel(habit)
}
+ fun onSnoozeTimePicked(habitGroup: HabitGroup?, hour: Int, minute: Int) {
+ val time: Long = getUpcomingTimeInMillis(hour, minute)
+ reminderScheduler.scheduleAtTime(habitGroup!!, time)
+ notificationTray.cancel(habitGroup)
+ }
+
fun onDismiss(habit: Habit) {
if (preferences.shouldMakeNotificationsSticky()) {
// This is a workaround to keep sticky notifications non-dismissible in Android 14+.
@@ -75,6 +100,16 @@ class ReminderController @Inject constructor(
}
}
+ fun onDismiss(habitGroup: HabitGroup) {
+ if (preferences.shouldMakeNotificationsSticky()) {
+ // This is a workaround to keep sticky notifications non-dismissible in Android 14+.
+ // If the notification is dismissed, we immediately reshow it.
+ notificationTray.reshow(habitGroup)
+ } else {
+ notificationTray.cancel(habitGroup)
+ }
+ }
+
private fun showSnoozeDelayPicker(habit: Habit, context: Context) {
context.sendBroadcast(Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS))
val intent = Intent(context, SnoozeDelayPickerActivity::class.java)
@@ -82,4 +117,12 @@ class ReminderController @Inject constructor(
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
}
+
+ private fun showSnoozeDelayPicker(habitGroup: HabitGroup, context: Context) {
+ context.sendBroadcast(Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS))
+ val intent = Intent(context, SnoozeDelayPickerActivity::class.java)
+ intent.data = Uri.parse(habitGroup.uriString)
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ context.startActivity(intent)
+ }
}
diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/receivers/ReminderReceiver.kt b/uhabits-android/src/main/java/org/isoron/uhabits/receivers/ReminderReceiver.kt
index 6eb10dc7e..a5e6db51a 100644
--- a/uhabits-android/src/main/java/org/isoron/uhabits/receivers/ReminderReceiver.kt
+++ b/uhabits-android/src/main/java/org/isoron/uhabits/receivers/ReminderReceiver.kt
@@ -27,6 +27,7 @@ import android.os.Build.VERSION.SDK_INT
import android.util.Log
import org.isoron.uhabits.HabitsApplication
import org.isoron.uhabits.core.models.Habit
+import org.isoron.uhabits.core.models.HabitGroup
import org.isoron.uhabits.core.models.Timestamp
import org.isoron.uhabits.core.utils.DateUtils.Companion.getStartOfTodayWithOffset
@@ -44,52 +45,81 @@ class ReminderReceiver : BroadcastReceiver() {
val app = context.applicationContext as HabitsApplication
val appComponent = app.component
val habits = appComponent.habitList
+ val habitGroups = appComponent.habitGroupList
val reminderController = appComponent.reminderController
Log.i(TAG, String.format("Received intent: %s", intent.toString()))
var habit: Habit? = null
+ var habitGroup: HabitGroup? = null
+ var id: Long? = null
+ var type: String? = null
val today: Long = getStartOfTodayWithOffset()
val data = intent.data
- if (data != null) habit = habits.getById(ContentUris.parseId(data))
+ if (data != null) {
+ type = data.pathSegments[0]
+ id = ContentUris.parseId(data)
+ when (type) {
+ "habit" -> habit = habits.getById(id) ?: habitGroups.getHabitByID(id)
+ "habitgroup" -> habitGroup = habitGroups.getById(id)
+ }
+ }
val timestamp = intent.getLongExtra("timestamp", today)
val reminderTime = intent.getLongExtra("reminderTime", today)
try {
when (intent.action) {
ACTION_SHOW_REMINDER -> {
- if (habit == null) return
+ if (id == null) return
Log.d(
"ReminderReceiver",
String.format(
- "onShowReminder habit=%d timestamp=%d reminderTime=%d",
- habit.id,
+ "onShowReminder %s=%d timestamp=%d reminderTime=%d",
+ type,
+ id,
timestamp,
reminderTime
)
)
- reminderController.onShowReminder(
- habit,
- Timestamp(timestamp),
- reminderTime
- )
+ if (habit != null) {
+ reminderController.onShowReminder(
+ habit,
+ Timestamp(timestamp),
+ reminderTime
+ )
+ } else {
+ reminderController.onShowReminder(
+ habitGroup!!,
+ Timestamp(timestamp),
+ reminderTime
+ )
+ }
}
ACTION_DISMISS_REMINDER -> {
- if (habit == null) return
- Log.d("ReminderReceiver", String.format("onDismiss habit=%d", habit.id))
- reminderController.onDismiss(habit)
+ if (id == null) return
+ Log.d("ReminderReceiver", String.format("onDismiss %s=%d", type, id))
+ if (habit != null) {
+ reminderController.onDismiss(habit)
+ } else {
+ reminderController.onDismiss(habitGroup!!)
+ }
}
ACTION_SNOOZE_REMINDER -> {
- if (habit == null) return
+ if (id == null) return
if (SDK_INT < Build.VERSION_CODES.S) {
Log.d(
"ReminderReceiver",
- String.format("onSnoozePressed habit=%d", habit.id)
+ String.format("onSnoozePressed %s=%d", type, id)
)
- reminderController.onSnoozePressed(habit, context)
+ if (habit != null) {
+ reminderController.onSnoozePressed(habit, context)
+ } else {
+ reminderController.onSnoozePressed(habitGroup!!, context)
+ }
} else {
Log.w(
"ReminderReceiver",
String.format(
- "onSnoozePressed habit=%d, should be deactivated in recent versions.",
- habit.id
+ "onSnoozePressed %s=%d, should be deactivated in recent versions.",
+ type,
+ id
)
)
}
diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/widgets/BaseWidgetProvider.kt b/uhabits-android/src/main/java/org/isoron/uhabits/widgets/BaseWidgetProvider.kt
index 1a41d0f4f..a22fbb4ae 100644
--- a/uhabits-android/src/main/java/org/isoron/uhabits/widgets/BaseWidgetProvider.kt
+++ b/uhabits-android/src/main/java/org/isoron/uhabits/widgets/BaseWidgetProvider.kt
@@ -27,15 +27,17 @@ import android.widget.RemoteViews
import org.isoron.uhabits.HabitsApplication
import org.isoron.uhabits.R
import org.isoron.uhabits.core.models.Habit
+import org.isoron.uhabits.core.models.HabitGroup
+import org.isoron.uhabits.core.models.HabitGroupList
import org.isoron.uhabits.core.models.HabitList
import org.isoron.uhabits.core.models.HabitNotFoundException
import org.isoron.uhabits.core.preferences.Preferences
import org.isoron.uhabits.core.preferences.WidgetPreferences
import org.isoron.uhabits.utils.InterfaceUtils.dpToPixels
-import java.util.ArrayList
abstract class BaseWidgetProvider : AppWidgetProvider() {
private lateinit var habits: HabitList
+ private lateinit var habitGroups: HabitGroupList
lateinit var preferences: Preferences
private set
private lateinit var widgetPrefs: WidgetPreferences
@@ -112,12 +114,26 @@ abstract class BaseWidgetProvider : AppWidgetProvider() {
val selectedIds = widgetPrefs.getHabitIdsFromWidgetId(widgetId)
val selectedHabits = ArrayList(selectedIds.size)
for (id in selectedIds) {
- val h = habits.getById(id) ?: throw HabitNotFoundException()
- selectedHabits.add(h)
+ val h = habits.getById(id) ?: habitGroups.getHabitByID(id)
+ if (h != null) {
+ selectedHabits.add(h)
+ }
}
return selectedHabits
}
+ protected fun getHabitGroupsFromWidgetId(widgetId: Int): List {
+ val selectedIds = widgetPrefs.getHabitIdsFromWidgetId(widgetId)
+ val selectedHabitGroups = ArrayList(selectedIds.size)
+ for (id in selectedIds) {
+ val hgr = habitGroups.getById(id)
+ if (hgr != null) {
+ selectedHabitGroups.add(hgr)
+ }
+ }
+ return selectedHabitGroups
+ }
+
protected abstract fun getWidgetFromId(
context: Context,
id: Int
@@ -159,6 +175,7 @@ abstract class BaseWidgetProvider : AppWidgetProvider() {
private fun updateDependencies(context: Context) {
val app = context.applicationContext as HabitsApplication
habits = app.component.habitList
+ habitGroups = app.component.habitGroupList
preferences = app.component.preferences
widgetPrefs = app.component.widgetPreferences
}
diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/widgets/FrequencyWidget.kt b/uhabits-android/src/main/java/org/isoron/uhabits/widgets/FrequencyWidget.kt
index 88c6b7655..9ae960695 100644
--- a/uhabits-android/src/main/java/org/isoron/uhabits/widgets/FrequencyWidget.kt
+++ b/uhabits-android/src/main/java/org/isoron/uhabits/widgets/FrequencyWidget.kt
@@ -25,32 +25,51 @@ import android.view.View
import org.isoron.platform.gui.toInt
import org.isoron.uhabits.activities.common.views.FrequencyChart
import org.isoron.uhabits.core.models.Habit
+import org.isoron.uhabits.core.models.HabitGroup
+import org.isoron.uhabits.core.ui.screens.habits.show.views.FrequencyCardPresenter
import org.isoron.uhabits.core.ui.views.WidgetTheme
import org.isoron.uhabits.widgets.views.GraphWidgetView
-class FrequencyWidget(
+class FrequencyWidget private constructor(
context: Context,
widgetId: Int,
- private val habit: Habit,
+ private val habit: Habit?,
+ private val habitGroup: HabitGroup?,
private val firstWeekday: Int,
- stacked: Boolean = false
+ stacked: Boolean
) : BaseWidget(context, widgetId, stacked) {
+
+ constructor(context: Context, widgetId: Int, habit: Habit, firstWeekday: Int, stacked: Boolean = false) : this(context, widgetId, habit, null, firstWeekday, stacked)
+ constructor(context: Context, widgetId: Int, habitGroup: HabitGroup, firstWeekday: Int, stacked: Boolean = false) : this(context, widgetId, null, habitGroup, firstWeekday, stacked)
+
override val defaultHeight: Int = 200
override val defaultWidth: Int = 200
- override fun getOnClickPendingIntent(context: Context): PendingIntent =
- pendingIntentFactory.showHabit(habit)
+ override fun getOnClickPendingIntent(context: Context): PendingIntent {
+ return if (habit != null) {
+ pendingIntentFactory.showHabit(habit)
+ } else {
+ pendingIntentFactory.showHabitGroup(habitGroup!!)
+ }
+ }
override fun refreshData(v: View) {
val widgetView = v as GraphWidgetView
- widgetView.setTitle(habit.name)
+ widgetView.setTitle(habit?.name ?: habitGroup!!.name)
widgetView.setBackgroundAlpha(preferedBackgroundAlpha)
if (preferedBackgroundAlpha >= 255) widgetView.setShadowAlpha(0x4f)
+ val color = habit?.color ?: habitGroup!!.color
+ val isNumerical = habit?.isNumerical ?: true
+ val frequency = if (habit != null) {
+ habit.originalEntries.computeWeekdayFrequency(habit.isNumerical)
+ } else {
+ FrequencyCardPresenter.getFrequenciesFromHabitGroup(habitGroup!!)
+ }
(widgetView.dataView as FrequencyChart).apply {
setFirstWeekday(firstWeekday)
- setColor(WidgetTheme().color(habit.color).toInt())
- setIsNumerical(habit.isNumerical)
- setFrequency(habit.originalEntries.computeWeekdayFrequency(habit.isNumerical))
+ setColor(WidgetTheme().color(color).toInt())
+ setIsNumerical(isNumerical)
+ setFrequency(frequency)
}
}
diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/widgets/FrequencyWidgetProvider.kt b/uhabits-android/src/main/java/org/isoron/uhabits/widgets/FrequencyWidgetProvider.kt
index 698f002f4..dc2f18fd2 100644
--- a/uhabits-android/src/main/java/org/isoron/uhabits/widgets/FrequencyWidgetProvider.kt
+++ b/uhabits-android/src/main/java/org/isoron/uhabits/widgets/FrequencyWidgetProvider.kt
@@ -24,15 +24,29 @@ import android.content.Context
class FrequencyWidgetProvider : BaseWidgetProvider() {
override fun getWidgetFromId(context: Context, id: Int): BaseWidget {
val habits = getHabitsFromWidgetId(id)
- return if (habits.size == 1) {
- FrequencyWidget(
- context,
- id,
- habits[0],
- preferences.firstWeekdayInt
- )
+ if (habits.isNotEmpty()) {
+ return if (habits.size == 1) {
+ FrequencyWidget(
+ context,
+ id,
+ habits[0],
+ preferences.firstWeekdayInt
+ )
+ } else {
+ StackWidget(context, id, StackWidgetType.FREQUENCY, habits)
+ }
} else {
- StackWidget(context, id, StackWidgetType.FREQUENCY, habits)
+ val habitGroups = getHabitGroupsFromWidgetId(id)
+ return if (habitGroups.size == 1) {
+ FrequencyWidget(
+ context,
+ id,
+ habitGroups[0],
+ preferences.firstWeekdayInt
+ )
+ } else {
+ StackWidget(context, id, StackWidgetType.FREQUENCY, habitGroups, true)
+ }
}
}
}
diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/widgets/ScoreWidget.kt b/uhabits-android/src/main/java/org/isoron/uhabits/widgets/ScoreWidget.kt
index 8be9dfed5..b6786cb66 100644
--- a/uhabits-android/src/main/java/org/isoron/uhabits/widgets/ScoreWidget.kt
+++ b/uhabits-android/src/main/java/org/isoron/uhabits/widgets/ScoreWidget.kt
@@ -25,42 +25,62 @@ import android.view.View
import org.isoron.platform.gui.toInt
import org.isoron.uhabits.activities.common.views.ScoreChart
import org.isoron.uhabits.core.models.Habit
+import org.isoron.uhabits.core.models.HabitGroup
import org.isoron.uhabits.core.ui.screens.habits.show.views.ScoreCardPresenter
import org.isoron.uhabits.core.ui.views.WidgetTheme
import org.isoron.uhabits.widgets.views.GraphWidgetView
-class ScoreWidget(
+class ScoreWidget private constructor(
context: Context,
id: Int,
- private val habit: Habit,
- stacked: Boolean = false
+ private val habit: Habit?,
+ private val habitGroup: HabitGroup?,
+ stacked: Boolean
) : BaseWidget(context, id, stacked) {
+ constructor(context: Context, id: Int, habit: Habit, stacked: Boolean = false) : this(context, id, habit, null, stacked)
+ constructor(context: Context, id: Int, habitGroup: HabitGroup, stacked: Boolean = false) : this(context, id, null, habitGroup, stacked)
+
override val defaultHeight: Int = 300
override val defaultWidth: Int = 300
- override fun getOnClickPendingIntent(context: Context): PendingIntent =
- pendingIntentFactory.showHabit(habit)
+ override fun getOnClickPendingIntent(context: Context): PendingIntent {
+ return if (habit != null) {
+ pendingIntentFactory.showHabit(habit)
+ } else {
+ pendingIntentFactory.showHabitGroup(habitGroup!!)
+ }
+ }
override fun refreshData(view: View) {
- val viewModel = ScoreCardPresenter.buildState(
- habit = habit,
- firstWeekday = prefs.firstWeekdayInt,
- spinnerPosition = prefs.scoreCardSpinnerPosition,
- theme = WidgetTheme()
- )
+ val viewModel = if (habit != null) {
+ ScoreCardPresenter.buildState(
+ habit = habit,
+ firstWeekday = prefs.firstWeekdayInt,
+ spinnerPosition = prefs.scoreCardSpinnerPosition,
+ theme = WidgetTheme()
+ )
+ } else {
+ ScoreCardPresenter.buildState(
+ habitGroup = habitGroup!!,
+ firstWeekday = prefs.firstWeekdayInt,
+ spinnerPosition = prefs.scoreCardSpinnerPosition,
+ theme = WidgetTheme()
+ )
+ }
val widgetView = view as GraphWidgetView
widgetView.setBackgroundAlpha(preferedBackgroundAlpha)
if (preferedBackgroundAlpha >= 255) widgetView.setShadowAlpha(0x4f)
+ val color = habit?.color ?: habitGroup!!.color
(widgetView.dataView as ScoreChart).apply {
setIsTransparencyEnabled(true)
setBucketSize(viewModel.bucketSize)
- setColor(WidgetTheme().color(habit.color).toInt())
+ setColor(WidgetTheme().color(color).toInt())
setScores(viewModel.scores)
}
}
override fun buildView() =
GraphWidgetView(context, ScoreChart(context)).apply {
- setTitle(habit.name)
+ setTitle(habit?.name ?: habitGroup!!.name)
}
}
diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/widgets/ScoreWidgetProvider.kt b/uhabits-android/src/main/java/org/isoron/uhabits/widgets/ScoreWidgetProvider.kt
index 160054da1..794413219 100644
--- a/uhabits-android/src/main/java/org/isoron/uhabits/widgets/ScoreWidgetProvider.kt
+++ b/uhabits-android/src/main/java/org/isoron/uhabits/widgets/ScoreWidgetProvider.kt
@@ -23,10 +23,19 @@ import android.content.Context
class ScoreWidgetProvider : BaseWidgetProvider() {
override fun getWidgetFromId(context: Context, id: Int): BaseWidget {
val habits = getHabitsFromWidgetId(id)
- return if (habits.size == 1) {
- ScoreWidget(context, id, habits[0])
+ if (habits.isNotEmpty()) {
+ return if (habits.size == 1) {
+ ScoreWidget(context, id, habits[0])
+ } else {
+ StackWidget(context, id, StackWidgetType.SCORE, habits)
+ }
} else {
- StackWidget(context, id, StackWidgetType.SCORE, habits)
+ val habitGroups = getHabitGroupsFromWidgetId(id)
+ return if (habitGroups.size == 1) {
+ ScoreWidget(context, id, habitGroups[0])
+ } else {
+ StackWidget(context, id, StackWidgetType.SCORE, habitGroups, true)
+ }
}
}
}
diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/widgets/StackWidget.kt b/uhabits-android/src/main/java/org/isoron/uhabits/widgets/StackWidget.kt
index 6c59c1e1d..47e906538 100644
--- a/uhabits-android/src/main/java/org/isoron/uhabits/widgets/StackWidget.kt
+++ b/uhabits-android/src/main/java/org/isoron/uhabits/widgets/StackWidget.kt
@@ -28,14 +28,20 @@ import android.view.View
import android.widget.RemoteViews
import org.isoron.platform.utils.StringUtils
import org.isoron.uhabits.core.models.Habit
+import org.isoron.uhabits.core.models.HabitGroup
-class StackWidget(
+class StackWidget private constructor(
context: Context,
widgetId: Int,
private val widgetType: StackWidgetType,
- private val habits: List,
- stacked: Boolean = true
+ private val habits: List?,
+ private val habitGroups: List?,
+ stacked: Boolean
) : BaseWidget(context, widgetId, stacked) {
+
+ constructor(context: Context, widgetId: Int, widgetType: StackWidgetType, habits: List, stacked: Boolean = true) : this(context, widgetId, widgetType, habits, null, stacked)
+ constructor(context: Context, widgetId: Int, widgetType: StackWidgetType, habitGroups: List, stacked: Boolean = true, isHabitGroups: Boolean = false) : this(context, widgetId, widgetType, null, habitGroups, stacked)
+
override val defaultHeight: Int = 0
override val defaultWidth: Int = 0
@@ -55,7 +61,11 @@ class StackWidget(
val remoteViews =
RemoteViews(context.packageName, StackWidgetType.getStackWidgetLayoutId(widgetType))
val serviceIntent = Intent(context, StackWidgetService::class.java)
- val habitIds = StringUtils.joinLongs(habits.map { it.id!! }.toLongArray())
+ val habitIds = if (habits != null) {
+ StringUtils.joinLongs(habits.map { it.id!! }.toLongArray())
+ } else {
+ StringUtils.joinLongs(habitGroups!!.map { it.id!! }.toLongArray())
+ }
serviceIntent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, id)
serviceIntent.putExtra(StackWidgetService.WIDGET_TYPE, widgetType.value)
@@ -73,9 +83,14 @@ class StackWidget(
StackWidgetType.getStackWidgetAdapterViewId(widgetType),
StackWidgetType.getStackWidgetEmptyViewId(widgetType)
)
+ val pendingIntentTemplate = if (habits != null) {
+ StackWidgetType.getPendingIntentTemplate(pendingIntentFactory, widgetType, habits)
+ } else {
+ StackWidgetType.getPendingIntentTemplate(pendingIntentFactory, widgetType, true)
+ }
remoteViews.setPendingIntentTemplate(
StackWidgetType.getStackWidgetAdapterViewId(widgetType),
- StackWidgetType.getPendingIntentTemplate(pendingIntentFactory, widgetType, habits)
+ pendingIntentTemplate
)
return remoteViews
}
diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/widgets/StackWidgetType.kt b/uhabits-android/src/main/java/org/isoron/uhabits/widgets/StackWidgetType.kt
index 1e3a5d217..b8929f5f6 100644
--- a/uhabits-android/src/main/java/org/isoron/uhabits/widgets/StackWidgetType.kt
+++ b/uhabits-android/src/main/java/org/isoron/uhabits/widgets/StackWidgetType.kt
@@ -24,7 +24,6 @@ import org.isoron.uhabits.R
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.Timestamp
import org.isoron.uhabits.intents.PendingIntentFactory
-import java.lang.IllegalStateException
enum class StackWidgetType(val value: Int) {
CHECKMARK(0), FREQUENCY(1), SCORE(2), // habit strength widget
@@ -95,6 +94,17 @@ enum class StackWidgetType(val value: Int) {
}
}
+ fun getPendingIntentTemplate(
+ factory: PendingIntentFactory,
+ widgetType: StackWidgetType,
+ isHabitGroups: Boolean
+ ): PendingIntent {
+ return when (widgetType) {
+ CHECKMARK, HISTORY, STREAKS, TARGET -> throw RuntimeException()
+ FREQUENCY, SCORE -> factory.showHabitGroupTemplate()
+ }
+ }
+
fun getIntentFillIn(
factory: PendingIntentFactory,
widgetType: StackWidgetType,
diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/widgets/activities/HabitPickerDialog.kt b/uhabits-android/src/main/java/org/isoron/uhabits/widgets/activities/HabitPickerDialog.kt
index 67ad8ae2d..2f2dc9682 100644
--- a/uhabits-android/src/main/java/org/isoron/uhabits/widgets/activities/HabitPickerDialog.kt
+++ b/uhabits-android/src/main/java/org/isoron/uhabits/widgets/activities/HabitPickerDialog.kt
@@ -25,7 +25,6 @@ import android.appwidget.AppWidgetManager.INVALID_APPWIDGET_ID
import android.content.Intent
import android.os.Bundle
import android.widget.ArrayAdapter
-import android.widget.Button
import android.widget.ListView
import android.widget.TextView
import org.isoron.uhabits.HabitsApplication
@@ -44,6 +43,10 @@ class NumericalHabitPickerDialog : HabitPickerDialog() {
override fun getEmptyMessage() = R.string.no_numerical_habits
}
+class HabitAndGroupPickerDialog : HabitPickerDialog() {
+ override fun shouldShowGroups(): Boolean = true
+}
+
open class HabitPickerDialog : Activity() {
private var widgetId = 0
@@ -52,6 +55,8 @@ open class HabitPickerDialog : Activity() {
protected open fun shouldHideNumerical() = false
protected open fun shouldHideBoolean() = false
+
+ protected open fun shouldShowGroups() = false
protected open fun getEmptyMessage() = R.string.no_habits
override fun onCreate(savedInstanceState: Bundle?) {
@@ -59,6 +64,7 @@ open class HabitPickerDialog : Activity() {
val component = (applicationContext as HabitsApplication).component
AndroidThemeSwitcher(this, component.preferences).apply()
val habitList = component.habitList
+ val habitGroupList = component.habitGroupList
widgetPreferences = component.widgetPreferences
widgetUpdater = component.widgetUpdater
widgetId = intent.extras?.getInt(EXTRA_APPWIDGET_ID, INVALID_APPWIDGET_ID) ?: 0
@@ -73,6 +79,21 @@ open class HabitPickerDialog : Activity() {
habitNames.add(h.name)
}
+ for (hgr in habitGroupList) {
+ if (hgr.isArchived) continue
+ if (shouldShowGroups()) {
+ habitIds.add(hgr.id!!)
+ habitNames.add(hgr.name)
+ }
+
+ for (h in hgr.habitList) {
+ if (h.isArchived) continue
+ if (!h.isNumerical and shouldHideBoolean()) continue
+ habitIds.add(h.id!!)
+ habitNames.add(h.name)
+ }
+ }
+
if (habitNames.isEmpty()) {
setContentView(R.layout.widget_empty_activity)
findViewById(R.id.message).setText(getEmptyMessage())
@@ -81,7 +102,6 @@ open class HabitPickerDialog : Activity() {
setContentView(R.layout.widget_configure_activity)
val listView = findViewById(R.id.listView)
- val saveButton = findViewById