pull/2020/merge
Dharanish R 8 months ago committed by GitHub
commit ff7ad445ab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -6,3 +6,4 @@ android.enableJetifier=true
android.defaults.buildfeatures.buildconfig=true android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=false android.nonTransitiveRClass=false
android.nonFinalResIds=false android.nonFinalResIds=false
org.gradle.java.installations.auto-download=true

@ -53,7 +53,7 @@ class HabitCardViewTest : BaseViewTest() {
.getByInterval(today.minus(300), today) .getByInterval(today.minus(300), today)
.map { it.value }.toIntArray() .map { it.value }.toIntArray()
view = component.getHabitCardViewFactory().create().apply { view = component.getHabitCardViewFactory().createHabitCard().apply {
habit = habit1 habit = habit1
values = entries values = entries
score = habit1.scores[today].value score = habit1.scores[today].value

@ -42,6 +42,22 @@
android:value=".activities.habits.list.ListHabitsActivity" /> android:value=".activities.habits.list.ListHabitsActivity" />
</activity> </activity>
<activity
android:name=".activities.habits.list.HabitGroupPickerDialog"
android:exported="true">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value=".activities.habits.list.ListHabitsActivity" />
</activity>
<activity
android:name=".activities.habits.edit.EditHabitGroupActivity"
android:exported="true">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value=".activities.habits.list.ListHabitsActivity" />
</activity>
<meta-data <meta-data
android:name="com.google.android.backup.api_key" android:name="com.google.android.backup.api_key"
android:value="AEdPqrEAAAAI6aeWncbnMNo8E5GWeZ44dlc5cQ7tCROwFhOtiw" /> android:value="AEdPqrEAAAAI6aeWncbnMNo8E5GWeZ44dlc5cQ7tCROwFhOtiw" />
@ -72,6 +88,14 @@
android:value=".activities.habits.list.ListHabitsActivity" /> android:value=".activities.habits.list.ListHabitsActivity" />
</activity> </activity>
<activity
android:name=".activities.habits.show.ShowHabitGroupActivity"
android:label="@string/title_activity_show_habit">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value=".activities.habits.list.ListHabitsActivity" />
</activity>
<activity <activity
android:name=".activities.settings.SettingsActivity" android:name=".activities.settings.SettingsActivity"
android:label="@string/settings"> android:label="@string/settings">
@ -112,6 +136,15 @@
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name=".widgets.activities.HabitAndGroupPickerDialog"
android:exported="true"
android:theme="@style/Theme.AppCompat.Light.Dialog">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<activity <activity
android:name=".activities.about.AboutActivity" android:name=".activities.about.AboutActivity"
android:label="@string/about"> android:label="@string/about">

@ -79,6 +79,10 @@ class HabitsApplication : Application() {
val habitList = component.habitList val habitList = component.habitList
for (h in habitList) h.recompute() for (h in habitList) h.recompute()
val habitGroupList = component.habitGroupList
for (hgr in habitGroupList) hgr.recompute()
habitGroupList.attachHabitsToGroups()
widgetUpdater = component.widgetUpdater.apply { widgetUpdater = component.widgetUpdater.apply {
startListening() startListening()
scheduleStartDayWidgetUpdate() scheduleStartDayWidgetUpdate()

@ -20,6 +20,7 @@ package org.isoron.uhabits.activities
import org.isoron.uhabits.AndroidDirFinder import org.isoron.uhabits.AndroidDirFinder
import org.isoron.uhabits.core.ui.screens.habits.list.ListHabitsBehavior 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 org.isoron.uhabits.core.ui.screens.habits.show.ShowHabitMenuPresenter
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
@ -33,3 +34,13 @@ constructor(
return androidDirFinder.getFilesDir("CSV")!! return androidDirFinder.getFilesDir("CSV")!!
} }
} }
class HabitGroupsDirFinder @Inject
constructor(
private val androidDirFinder: AndroidDirFinder
) : ShowHabitGroupMenuPresenter.System, ListHabitsBehavior.DirFinder {
override fun getCSVOutputDir(): File {
return androidDirFinder.getFilesDir("CSV")!!
}
}

@ -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.CommandRunner
import org.isoron.uhabits.core.commands.CreateHabitCommand import org.isoron.uhabits.core.commands.CreateHabitCommand
import org.isoron.uhabits.core.commands.EditHabitCommand 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.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.HabitType
import org.isoron.uhabits.core.models.NumericalHabitType import org.isoron.uhabits.core.models.NumericalHabitType
import org.isoron.uhabits.core.models.PaletteColor import org.isoron.uhabits.core.models.PaletteColor
@ -76,6 +77,7 @@ class EditHabitActivity : AppCompatActivity() {
var habitId = -1L var habitId = -1L
lateinit var habitType: HabitType lateinit var habitType: HabitType
var parentGroup: HabitGroup? = null
var unit = "" var unit = ""
var color = PaletteColor(11) var color = PaletteColor(11)
var androidColor = 0 var androidColor = 0
@ -98,10 +100,21 @@ class EditHabitActivity : AppCompatActivity() {
binding.toolbar.applyToolbarInsets() binding.toolbar.applyToolbarInsets()
setContentView(binding.root) 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")) { if (intent.hasExtra("habitId")) {
binding.toolbar.title = getString(R.string.edit_habit) binding.toolbar.title = getString(R.string.edit_habit)
habitId = intent.getLongExtra("habitId", -1) habitId = intent.getLongExtra("habitId", -1L)
val habit = component.habitList.getById(habitId)!! val habitList = if (parentGroup != null) {
parentGroup!!.habitList
} else {
component.habitList
}
val habit = habitList.getById(habitId)!!
habitType = habit.type habitType = habit.type
color = habit.color color = habit.color
freqNum = habit.frequency.numerator freqNum = habit.frequency.numerator
@ -262,9 +275,14 @@ class EditHabitActivity : AppCompatActivity() {
val component = (application as HabitsApplication).component val component = (application as HabitsApplication).component
val habit = component.modelFactory.buildHabit() val habit = component.modelFactory.buildHabit()
var original: Habit? = null val habitList = if (parentGroup != null) {
if (habitId >= 0) { parentGroup!!.habitList
original = component.habitList.getById(habitId)!! } else {
component.habitList
}
if (habitId > 0) {
val original = habitList.getById(habitId)!!
habit.copyFrom(original) habit.copyFrom(original)
} }
@ -285,21 +303,31 @@ class EditHabitActivity : AppCompatActivity() {
habit.unit = binding.unitInput.text.trim().toString() habit.unit = binding.unitInput.text.trim().toString()
} }
habit.type = habitType 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( EditHabitCommand(
component.habitList, habitList,
habitId, habitId,
habit habit
) )
} else { } else {
CreateHabitCommand( CreateHabitCommand(
component.modelFactory, component.modelFactory,
component.habitList, habitList,
habit habit
) )
} }
component.commandRunner.run(command) component.commandRunner.run(command)
if (habit.groupId != null) {
val habitGroupList = component.habitGroupList
val refreshCommand = RefreshParentGroupCommand(habit, habitGroupList)
component.commandRunner.run(refreshCommand)
}
finish() finish()
} }

@ -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 = "<font color=#FFFFFF>${getString(resId)}</font>"
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())
}
}
}

@ -29,7 +29,7 @@ import org.isoron.uhabits.core.models.HabitType
import org.isoron.uhabits.databinding.SelectHabitTypeBinding import org.isoron.uhabits.databinding.SelectHabitTypeBinding
import org.isoron.uhabits.intents.IntentFactory import org.isoron.uhabits.intents.IntentFactory
class HabitTypeDialog : AppCompatDialogFragment() { class HabitTypeDialog(val groupId: Long? = null) : AppCompatDialogFragment() {
override fun getTheme() = R.style.Translucent override fun getTheme() = R.style.Translucent
override fun onCreateView( override fun onCreateView(
@ -40,17 +40,27 @@ class HabitTypeDialog : AppCompatDialogFragment() {
val binding = SelectHabitTypeBinding.inflate(inflater, container, false) val binding = SelectHabitTypeBinding.inflate(inflater, container, false)
binding.buttonYesNo.setOnClickListener { binding.buttonYesNo.setOnClickListener {
val intent = IntentFactory().startEditActivity(requireActivity(), HabitType.YES_NO.value) val intent = IntentFactory().startEditActivity(requireActivity(), HabitType.YES_NO.value, groupId)
startActivity(intent) startActivity(intent)
dismiss() dismiss()
} }
binding.buttonMeasurable.setOnClickListener { binding.buttonMeasurable.setOnClickListener {
val intent = IntentFactory().startEditActivity(requireActivity(), HabitType.NUMERICAL.value) val intent = IntentFactory().startEditActivity(requireActivity(), HabitType.NUMERICAL.value, groupId)
startActivity(intent) startActivity(intent)
dismiss() dismiss()
} }
binding.buttonHabitGroup.setOnClickListener {
val intent = IntentFactory().startEditGroupActivity(requireActivity())
startActivity(intent)
dismiss()
}
if (groupId != null) {
binding.buttonHabitGroup.visibility = View.GONE
}
binding.background.setOnClickListener { binding.background.setOnClickListener {
dismiss() dismiss()
} }

@ -0,0 +1,91 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* 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 <http://www.gnu.org/licenses/>.
*/
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<Habit>
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<Long>()
val groupNames = ArrayList<String>()
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<TextView>(R.id.message).setText(R.string.no_habit_groups)
return
}
setContentView(R.layout.widget_configure_activity)
val listView = findViewById<ListView>(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()
}
}

@ -179,10 +179,20 @@ class ListHabitsActivity : AppCompatActivity(), Preferences.Listener {
val habitId = intent.extras?.getLong("habit") val habitId = intent.extras?.getLong("habit")
val timestamp = intent.extras?.getLong("timestamp") val timestamp = intent.extras?.getLong("timestamp")
if (habitId != null && timestamp != null) { 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)) 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 intent = null
} }

@ -148,7 +148,7 @@ class ListHabitsRootView @Inject constructor(
private fun updateEmptyView() { private fun updateEmptyView() {
if (listAdapter.itemCount == 0) { if (listAdapter.itemCount == 0) {
if (listAdapter.hasNoHabit()) { if (listAdapter.hasNoHabit() && listAdapter.hasNoHabitGroup()) {
llEmpty.showEmpty() llEmpty.showEmpty()
} else { } else {
llEmpty.showDone() llEmpty.showDone()

@ -41,10 +41,12 @@ import org.isoron.uhabits.core.commands.ChangeHabitColorCommand
import org.isoron.uhabits.core.commands.Command import org.isoron.uhabits.core.commands.Command
import org.isoron.uhabits.core.commands.CommandRunner import org.isoron.uhabits.core.commands.CommandRunner
import org.isoron.uhabits.core.commands.CreateHabitCommand 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.DeleteHabitsCommand
import org.isoron.uhabits.core.commands.EditHabitCommand import org.isoron.uhabits.core.commands.EditHabitCommand
import org.isoron.uhabits.core.commands.UnarchiveHabitsCommand import org.isoron.uhabits.core.commands.UnarchiveHabitsCommand
import org.isoron.uhabits.core.models.Habit 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.models.PaletteColor
import org.isoron.uhabits.core.preferences.Preferences import org.isoron.uhabits.core.preferences.Preferences
import org.isoron.uhabits.core.tasks.TaskRunner import org.isoron.uhabits.core.tasks.TaskRunner
@ -164,8 +166,8 @@ class ListHabitsScreen
activity.startActivity(intent) activity.startActivity(intent)
} }
override fun showSelectHabitTypeDialog() { override fun showSelectHabitTypeDialog(groupId: Long?) {
val dialog = HabitTypeDialog() val dialog = HabitTypeDialog(groupId)
dialog.show(activity.supportFragmentManager, "habitType") dialog.show(activity.supportFragmentManager, "habitType")
} }
@ -178,6 +180,16 @@ class ListHabitsScreen
activity.startActivity(intent) activity.startActivity(intent)
} }
override fun showEditHabitGroupScreen(selected: List<HabitGroup>) {
val intent = intentFactory.startEditGroupActivity(activity, selected[0])
activity.startActivity(intent)
}
override fun showHabitGroupPickerDialog(selected: List<Habit>) {
val intent = intentFactory.startHabitGroupPickerActivity(activity, selected)
activity.startActivity(intent)
}
override fun showFAQScreen() { override fun showFAQScreen() {
val intent = intentFactory.viewFAQ(activity) val intent = intentFactory.viewFAQ(activity)
activity.startActivity(intent) activity.startActivity(intent)
@ -188,6 +200,11 @@ class ListHabitsScreen
activity.startActivity(intent) activity.startActivity(intent)
} }
override fun showHabitGroupScreen(hgr: HabitGroup) {
val intent = intentFactory.startShowHabitGroupActivity(activity, hgr)
activity.startActivity(intent)
}
fun showImportScreen() { fun showImportScreen() {
val intent = intentFactory.openDocument() val intent = intentFactory.openDocument()
activity.startActivityForResult(intent, REQUEST_OPEN_DOCUMENT) activity.startActivityForResult(intent, REQUEST_OPEN_DOCUMENT)
@ -312,6 +329,12 @@ class ListHabitsScreen
command.selected.size command.selected.size
) )
} }
is DeleteHabitGroupsCommand -> {
return activity.resources.getQuantityString(
R.plurals.toast_habits_deleted,
command.selected.size
)
}
is EditHabitCommand -> { is EditHabitCommand -> {
return activity.resources.getQuantityString(R.plurals.toast_habits_changed, 1) return activity.resources.getQuantityString(R.plurals.toast_habits_changed, 1)
} }

@ -75,14 +75,18 @@ class ListHabitsSelectionMenu @Inject constructor(
val itemColor = menu.findItem(R.id.action_color) val itemColor = menu.findItem(R.id.action_color)
val itemArchive = menu.findItem(R.id.action_archive_habit) val itemArchive = menu.findItem(R.id.action_archive_habit)
val itemUnarchive = menu.findItem(R.id.action_unarchive_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) val itemNotify = menu.findItem(R.id.action_notify)
itemColor.isVisible = true itemColor.isVisible = true
itemEdit.isVisible = behavior.canEdit() itemEdit.isVisible = behavior.canEdit()
itemArchive.isVisible = behavior.canArchive() itemArchive.isVisible = behavior.canArchive()
itemUnarchive.isVisible = behavior.canUnarchive() itemUnarchive.isVisible = behavior.canUnarchive()
itemRemoveFromGroup.isVisible = behavior.areSubHabits()
itemAddToGroup.isVisible = behavior.areHabits()
itemNotify.isVisible = prefs.isDeveloper itemNotify.isVisible = prefs.isDeveloper
activeActionMode?.title = listAdapter.selected.size.toString() activeActionMode?.title = (listAdapter.selectedHabits.size + listAdapter.selectedHabitGroups.size).toString()
return true return true
} }
override fun onDestroyActionMode(mode: ActionMode?) { override fun onDestroyActionMode(mode: ActionMode?) {
@ -106,6 +110,16 @@ class ListHabitsSelectionMenu @Inject constructor(
return true 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 -> { R.id.action_delete -> {
behavior.onDeleteHabits() behavior.onDeleteHabits()
return true return true
@ -117,7 +131,7 @@ class ListHabitsSelectionMenu @Inject constructor(
} }
R.id.action_notify -> { R.id.action_notify -> {
for (h in listAdapter.selected) for (h in listAdapter.selectedHabits)
notificationTray.show(h, DateUtils.getToday(), 0) notificationTray.show(h, DateUtils.getToday(), 0)
return true return true
} }

@ -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)
}
}
}

@ -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() {}
}

@ -18,10 +18,12 @@
*/ */
package org.isoron.uhabits.activities.habits.list.views package org.isoron.uhabits.activities.habits.list.views
import android.annotation.SuppressLint
import android.view.ViewGroup 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.activities.habits.list.MAX_CHECKMARK_COUNT
import org.isoron.uhabits.core.models.Habit 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.HabitList
import org.isoron.uhabits.core.models.HabitMatcher import org.isoron.uhabits.core.models.HabitMatcher
import org.isoron.uhabits.core.models.ModelObservable import org.isoron.uhabits.core.models.ModelObservable
@ -38,7 +40,7 @@ import javax.inject.Inject
* Provides data that backs a [HabitCardListView]. * 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. * also holds a list of items that have been selected.
*/ */
@ActivityScope @ActivityScope
@ -46,14 +48,16 @@ class HabitCardListAdapter @Inject constructor(
private val cache: HabitCardListCache, private val cache: HabitCardListCache,
private val preferences: Preferences, private val preferences: Preferences,
private val midnightTimer: MidnightTimer private val midnightTimer: MidnightTimer
) : RecyclerView.Adapter<HabitCardViewHolder?>(), ) : Adapter<HabitCardViewHolder?>(),
HabitCardListCache.Listener, HabitCardListCache.Listener,
MidnightTimer.MidnightListener, MidnightTimer.MidnightListener,
ListHabitsMenuBehavior.Adapter, ListHabitsMenuBehavior.Adapter,
ListHabitsSelectionMenuBehavior.Adapter { ListHabitsSelectionMenuBehavior.Adapter {
val observable: ModelObservable = ModelObservable() val observable: ModelObservable = ModelObservable()
private var listView: HabitCardListView? = null private var listView: HabitCardListView? = null
val selected: LinkedList<Habit> = LinkedList() val selectedHabits: LinkedList<Habit> = LinkedList()
val selectedHabitGroups: LinkedList<HabitGroup> = LinkedList()
override fun atMidnight() { override fun atMidnight() {
cache.refreshAllHabits() cache.refreshAllHabits()
} }
@ -66,17 +70,27 @@ class HabitCardListAdapter @Inject constructor(
return cache.hasNoHabit() return cache.hasNoHabit()
} }
fun hasNoHabitGroup(): Boolean {
return cache.hasNoHabitGroup()
}
/** /**
* Sets all items as not selected. * Sets all items as not selected.
*/ */
@SuppressLint("NotifyDataSetChanged")
override fun clearSelection() { override fun clearSelection() {
selected.clear() selectedHabits.clear()
selectedHabitGroups.clear()
notifyDataSetChanged() notifyDataSetChanged()
observable.notifyListeners() observable.notifyListeners()
} }
override fun getSelected(): List<Habit> { override fun getSelectedHabits(): List<Habit> {
return ArrayList(selected) return ArrayList(selectedHabits)
}
override fun getSelectedHabitGroups(): List<HabitGroup> {
return ArrayList(selectedHabitGroups)
} }
/** /**
@ -90,12 +104,20 @@ class HabitCardListAdapter @Inject constructor(
return cache.getHabitByPosition(position) 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 { override fun getItemCount(): Int {
return cache.habitCount return cache.itemCount
} }
override fun getItemId(position: Int): Long { 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 * @return true if selection is empty, false otherwise
*/ */
val isSelectionEmpty: Boolean val isSelectionEmpty: Boolean
get() = selected.isEmpty() get() = selectedHabits.isEmpty() && selectedHabitGroups.isEmpty()
val isSortable: Boolean val isSortable: Boolean
get() = cache.primaryOrder == HabitList.Order.BY_POSITION get() = cache.primaryOrder == HabitList.Order.BY_POSITION
@ -122,11 +144,18 @@ class HabitCardListAdapter @Inject constructor(
) { ) {
if (listView == null) return if (listView == null) return
val habit = cache.getHabitByPosition(position) val habit = cache.getHabitByPosition(position)
val score = cache.getScore(habit!!.id!!) if (habit != null) {
val checkmarks = cache.getCheckmarks(habit.id!!) val score = cache.getScore(habit.id!!)
val notes = cache.getNotes(habit.id!!) val checkmarks = cache.getCheckmarks(habit.id!!)
val selected = selected.contains(habit) val notes = cache.getNotes(habit.id!!)
listView!!.bindCardView(holder, habit, score, checkmarks, notes, selected) 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) { override fun onViewAttachedToWindow(holder: HabitCardViewHolder) {
@ -141,8 +170,22 @@ class HabitCardListAdapter @Inject constructor(
parent: ViewGroup, parent: ViewGroup,
viewType: Int viewType: Int
): HabitCardViewHolder { ): HabitCardViewHolder {
val view = listView!!.createHabitCardView() if (viewType == 0) {
return HabitCardViewHolder(view) 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!!) for (habit in selected) cache.remove(habit.id!!)
} }
override fun performRemoveHabitGroup(selected: List<HabitGroup>) {
for (hgr in selected) cache.remove(hgr.id!!)
}
/** /**
* Changes the order of habits on the adapter. * 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 * @param position position of the item to be toggled
*/ */
@SuppressLint("NotifyDataSetChanged")
fun toggleSelection(position: Int) { fun toggleSelection(position: Int) {
val h = getItem(position) ?: return val h = cache.getHabitByPosition(position)
val k = selected.indexOf(h) val hgr = cache.getHabitGroupByPosition(position)
if (k < 0) selected.add(h) else selected.remove(h) if (h != null) {
notifyDataSetChanged() 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 { init {

@ -50,12 +50,22 @@ class HabitCardListController @Inject constructor(
if (from == to) return if (from == to) return
cancelSelection() cancelSelection()
val habitFrom = adapter.getItem(from) val habitFrom = adapter.getHabit(from)
val habitTo = adapter.getItem(to) val habitTo = adapter.getHabit(to)
if (habitFrom == null || habitTo == null) return 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) adapter.performReorder(from, to)
behavior.onReorderHabit(habitFrom, habitTo) behavior.onReorderHabitGroup(hgrFrom, hgrTo)
} }
override fun onItemClick(position: Int) { override fun onItemClick(position: Int) {
@ -114,8 +124,15 @@ class HabitCardListController @Inject constructor(
*/ */
internal inner class NormalMode : Mode { internal inner class NormalMode : Mode {
override fun onItemClick(position: Int) { override fun onItemClick(position: Int) {
val habit = adapter.getItem(position) ?: return val habit = adapter.getHabit(position)
behavior.onClickHabit(habit) if (habit != null) {
behavior.onClickHabit(habit)
} else {
val hgr = adapter.getHabitGroup(position)
if (hgr != null) {
behavior.onClickHabitGroup(hgr)
}
}
} }
override fun onItemLongClick(position: Int): Boolean { override fun onItemLongClick(position: Int): Boolean {

@ -36,6 +36,7 @@ import dagger.Lazy
import org.isoron.uhabits.R import org.isoron.uhabits.R
import org.isoron.uhabits.activities.common.views.BundleSavedState import org.isoron.uhabits.activities.common.views.BundleSavedState
import org.isoron.uhabits.core.models.Habit import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.HabitGroup
import org.isoron.uhabits.inject.ActivityContext import org.isoron.uhabits.inject.ActivityContext
import javax.inject.Inject import javax.inject.Inject
@ -62,8 +63,13 @@ class HabitCardListView(
set(value) { set(value) {
field = value field = value
attachedHolders attachedHolders
.map { it.itemView as HabitCardView } .forEach {
.forEach { it.dataOffset = value } if (it.itemView is HabitCardView) {
(it.itemView as HabitCardView).dataOffset = value
} else {
(it.itemView as HabitGroupCardView).dataOffset = value
}
}
} }
private val attachedHolders = mutableListOf<HabitCardViewHolder>() private val attachedHolders = mutableListOf<HabitCardViewHolder>()
@ -79,7 +85,11 @@ class HabitCardListView(
} }
fun createHabitCardView(): HabitCardView { fun createHabitCardView(): HabitCardView {
return cardViewFactory.create() return cardViewFactory.createHabitCard()
}
fun createHabitGroupCardView(): HabitGroupCardView {
return cardViewFactory.createHabitGroupCard()
} }
fun bindCardView( fun bindCardView(
@ -110,8 +120,28 @@ class HabitCardListView(
return cardView 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) { fun attachCardView(holder: HabitCardViewHolder) {
(holder.itemView as HabitCardView).dataOffset = dataOffset (holder.itemView as? HabitCardView)?.dataOffset = dataOffset
attachedHolders.add(holder) attachedHolders.add(holder)
} }

@ -55,7 +55,8 @@ class HabitCardViewFactory
private val numberPanelFactory: NumberPanelViewFactory, private val numberPanelFactory: NumberPanelViewFactory,
private val behavior: ListHabitsBehavior 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( class HabitCardView(
@ -265,6 +266,15 @@ class HabitCardView(
} }
scoreRing.apply { scoreRing.apply {
setColor(c) 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 { checkmarkPanel.apply {
color = c color = c

@ -21,4 +21,4 @@ package org.isoron.uhabits.activities.habits.list.views
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
class HabitCardViewHolder(itemView: HabitCardView) : RecyclerView.ViewHolder(itemView) class HabitCardViewHolder(itemView1: HabitCardView?, itemView2: HabitGroupCardView?) : RecyclerView.ViewHolder(itemView1 ?: itemView2!!)

@ -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)
}
}
}

@ -18,7 +18,6 @@
*/ */
package org.isoron.uhabits.activities.habits.show package org.isoron.uhabits.activities.habits.show
import android.content.ContentUris
import android.os.Bundle import android.os.Bundle
import android.view.HapticFeedbackConstants import android.view.HapticFeedbackConstants
import android.view.Menu import android.view.Menu
@ -75,8 +74,15 @@ class ShowHabitActivity : AppCompatActivity(), CommandRunner.Listener {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val appComponent = (applicationContext as HabitsApplication).component val appComponent = (applicationContext as HabitsApplication).component
val habitList = appComponent.habitList val habitGroupList = appComponent.habitGroupList
habit = habitList.getById(ContentUris.parseId(intent.data!!))!! 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 preferences = appComponent.preferences
commandRunner = appComponent.commandRunner commandRunner = appComponent.commandRunner
widgetUpdater = appComponent.widgetUpdater widgetUpdater = appComponent.widgetUpdater

@ -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()
}
}
}

@ -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
}
}

@ -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)
}
}

@ -24,6 +24,7 @@ import org.isoron.uhabits.core.AppScope
import org.isoron.uhabits.core.commands.CommandRunner import org.isoron.uhabits.core.commands.CommandRunner
import org.isoron.uhabits.core.io.GenericImporter import org.isoron.uhabits.core.io.GenericImporter
import org.isoron.uhabits.core.io.Logging 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.HabitList
import org.isoron.uhabits.core.models.ModelFactory import org.isoron.uhabits.core.models.ModelFactory
import org.isoron.uhabits.core.preferences.Preferences import org.isoron.uhabits.core.preferences.Preferences
@ -50,6 +51,7 @@ interface HabitsApplicationComponent {
val genericImporter: GenericImporter val genericImporter: GenericImporter
val habitCardListCache: HabitCardListCache val habitCardListCache: HabitCardListCache
val habitList: HabitList val habitList: HabitList
val habitGroupList: HabitGroupList
val intentFactory: IntentFactory val intentFactory: IntentFactory
val intentParser: IntentParser val intentParser: IntentParser
val logging: Logging val logging: Logging

@ -26,9 +26,11 @@ import org.isoron.uhabits.core.commands.CommandRunner
import org.isoron.uhabits.core.database.Database import org.isoron.uhabits.core.database.Database
import org.isoron.uhabits.core.database.DatabaseOpener import org.isoron.uhabits.core.database.DatabaseOpener
import org.isoron.uhabits.core.io.Logging 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.HabitList
import org.isoron.uhabits.core.models.ModelFactory import org.isoron.uhabits.core.models.ModelFactory
import org.isoron.uhabits.core.models.sqlite.SQLModelFactory 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.models.sqlite.SQLiteHabitList
import org.isoron.uhabits.core.preferences.Preferences import org.isoron.uhabits.core.preferences.Preferences
import org.isoron.uhabits.core.preferences.WidgetPreferences import org.isoron.uhabits.core.preferences.WidgetPreferences
@ -61,9 +63,10 @@ class HabitsModule(dbFile: File) {
sys: IntentScheduler, sys: IntentScheduler,
commandRunner: CommandRunner, commandRunner: CommandRunner,
habitList: HabitList, habitList: HabitList,
habitGroupList: HabitGroupList,
widgetPreferences: WidgetPreferences widgetPreferences: WidgetPreferences
): ReminderScheduler { ): ReminderScheduler {
return ReminderScheduler(commandRunner, habitList, sys, widgetPreferences) return ReminderScheduler(commandRunner, habitList, habitGroupList, sys, widgetPreferences)
} }
@Provides @Provides
@ -97,6 +100,12 @@ class HabitsModule(dbFile: File) {
return list return list
} }
@Provides
@AppScope
fun getHabitGroupList(list: SQLiteHabitGroupList): HabitGroupList {
return list
}
@Provides @Provides
@AppScope @AppScope
fun getDatabaseOpener(opener: AndroidDatabaseOpener): DatabaseOpener { fun getDatabaseOpener(opener: AndroidDatabaseOpener): DatabaseOpener {

@ -25,10 +25,14 @@ import android.net.Uri
import org.isoron.uhabits.R import org.isoron.uhabits.R
import org.isoron.uhabits.activities.about.AboutActivity import org.isoron.uhabits.activities.about.AboutActivity
import org.isoron.uhabits.activities.habits.edit.EditHabitActivity 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.ShowHabitActivity
import org.isoron.uhabits.activities.habits.show.ShowHabitGroupActivity
import org.isoron.uhabits.activities.intro.IntroActivity import org.isoron.uhabits.activities.intro.IntroActivity
import org.isoron.uhabits.activities.settings.SettingsActivity import org.isoron.uhabits.activities.settings.SettingsActivity
import org.isoron.uhabits.core.models.Habit import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.HabitGroup
import javax.inject.Inject import javax.inject.Inject
class IntentFactory class IntentFactory
@ -60,9 +64,16 @@ class IntentFactory
fun startSettingsActivity(context: Context) = fun startSettingsActivity(context: Context) =
Intent(context, SettingsActivity::class.java) Intent(context, SettingsActivity::class.java)
fun startShowHabitActivity(context: Context, habit: Habit) = fun startShowHabitActivity(context: Context, habit: Habit): Intent {
Intent(context, ShowHabitActivity::class.java).apply { val intent = Intent(context, ShowHabitActivity::class.java)
data = Uri.parse(habit.uriString) 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) = fun viewFAQ(context: Context) =
@ -92,12 +103,33 @@ class IntentFactory
val intent = startEditActivity(context) val intent = startEditActivity(context)
intent.putExtra("habitId", habit.id) intent.putExtra("habitId", habit.id)
intent.putExtra("habitType", habit.type) intent.putExtra("habitType", habit.type)
if (habit.groupId != null) intent.putExtra("groupId", habit.groupId)
return intent return intent
} }
fun startEditActivity(context: Context, habitType: Int): Intent { fun startEditActivity(context: Context, habitType: Int, groupId: Long?): Intent {
val intent = startEditActivity(context) val intent = startEditActivity(context)
intent.putExtra("habitType", habitType) 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<Habit>): Intent {
val intent = Intent(context, HabitGroupPickerDialog::class.java)
val ids = selected.map { it.id!! }.toLongArray()
intent.putExtra("selected", ids)
return intent return intent
} }
} }

@ -24,6 +24,7 @@ import android.content.Intent
import android.net.Uri import android.net.Uri
import org.isoron.uhabits.core.AppScope import org.isoron.uhabits.core.AppScope
import org.isoron.uhabits.core.models.Habit 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.HabitList
import org.isoron.uhabits.core.models.Timestamp import org.isoron.uhabits.core.models.Timestamp
import org.isoron.uhabits.core.utils.DateUtils import org.isoron.uhabits.core.utils.DateUtils
@ -31,7 +32,10 @@ import javax.inject.Inject
@AppScope @AppScope
class IntentParser class IntentParser
@Inject constructor(private val habits: HabitList) { @Inject constructor(
private val habits: HabitList,
private val habitGroups: HabitGroupList
) {
fun parseCheckmarkIntent(intent: Intent): CheckmarkIntentData { fun parseCheckmarkIntent(intent: Intent): CheckmarkIntentData {
val uri = intent.data ?: throw IllegalArgumentException("uri is null") val uri = intent.data ?: throw IllegalArgumentException("uri is null")
@ -45,6 +49,7 @@ class IntentParser
private fun parseHabit(uri: Uri): Habit { private fun parseHabit(uri: Uri): Habit {
return habits.getById(parseId(uri)) return habits.getById(parseId(uri))
?: habitGroups.getHabitByID(parseId(uri))
?: throw IllegalArgumentException("habit not found") ?: throw IllegalArgumentException("habit not found")
} }

@ -29,6 +29,7 @@ import android.os.Build
import android.util.Log import android.util.Log
import org.isoron.uhabits.core.AppScope import org.isoron.uhabits.core.AppScope
import org.isoron.uhabits.core.models.Habit 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.SchedulerResult
import org.isoron.uhabits.core.reminders.ReminderScheduler.SystemScheduler import org.isoron.uhabits.core.reminders.ReminderScheduler.SystemScheduler
import org.isoron.uhabits.core.utils.DateFormats import org.isoron.uhabits.core.utils.DateFormats
@ -75,6 +76,16 @@ class IntentScheduler
return schedule(reminderTime, intent, RTC_WAKEUP) 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 { override fun scheduleWidgetUpdate(updateTime: Long): SchedulerResult {
val intent = pendingIntents.updateWidgets() val intent = pendingIntents.updateWidgets()
return schedule(updateTime, intent, RTC) return schedule(updateTime, intent, RTC)
@ -94,4 +105,15 @@ class IntentScheduler
String.format("Setting alarm (%s): %s", time, name) 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)
)
}
} }

@ -31,8 +31,10 @@ import android.net.Uri
import android.os.Build import android.os.Build
import org.isoron.uhabits.activities.habits.list.ListHabitsActivity import org.isoron.uhabits.activities.habits.list.ListHabitsActivity
import org.isoron.uhabits.activities.habits.show.ShowHabitActivity 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.AppScope
import org.isoron.uhabits.core.models.Habit 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.models.Timestamp
import org.isoron.uhabits.inject.AppContext import org.isoron.uhabits.inject.AppContext
import org.isoron.uhabits.receivers.ReminderReceiver import org.isoron.uhabits.receivers.ReminderReceiver
@ -69,6 +71,17 @@ class PendingIntentFactory
FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT 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 = fun removeRepetition(habit: Habit, timestamp: Timestamp?): PendingIntent =
getBroadcast( getBroadcast(
context, context,
@ -92,6 +105,17 @@ class PendingIntentFactory
) )
.getPendingIntent(0, FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT)!! .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 { fun showHabitTemplate(): PendingIntent {
return getActivity( return getActivity(
context, context,
@ -101,6 +125,15 @@ class PendingIntentFactory
) )
} }
fun showHabitGroupTemplate(): PendingIntent {
return getActivity(
context,
0,
Intent(context, ShowHabitGroupActivity::class.java),
getIntentTemplateFlags()
)
}
fun showHabitFillIn(habit: Habit) = fun showHabitFillIn(habit: Habit) =
Intent().apply { Intent().apply {
data = Uri.parse(habit.uriString) data = Uri.parse(habit.uriString)
@ -123,6 +156,23 @@ class PendingIntentFactory
FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT 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 = fun snoozeNotification(habit: Habit): PendingIntent =
getBroadcast( getBroadcast(
context, context,
@ -134,6 +184,17 @@ class PendingIntentFactory
FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT 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 = fun toggleCheckmark(habit: Habit, timestamp: Long?): PendingIntent =
getBroadcast( getBroadcast(
context, context,
@ -185,6 +246,26 @@ class PendingIntentFactory
putExtra("timestamp", timestamp.unixTime) 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 { private fun getIntentTemplateFlags(): Int {
var flags = 0 var flags = 0
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {

@ -35,6 +35,7 @@ import androidx.core.app.NotificationManagerCompat
import org.isoron.uhabits.R import org.isoron.uhabits.R
import org.isoron.uhabits.core.AppScope import org.isoron.uhabits.core.AppScope
import org.isoron.uhabits.core.models.Habit 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.models.Timestamp
import org.isoron.uhabits.core.preferences.Preferences import org.isoron.uhabits.core.preferences.Preferences
import org.isoron.uhabits.core.ui.NotificationTray import org.isoron.uhabits.core.ui.NotificationTray
@ -90,6 +91,34 @@ class AndroidNotificationTray
active.add(notificationId) 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( fun buildNotification(
habit: Habit, habit: Habit,
reminderTime: Long, reminderTime: Long,
@ -163,6 +192,58 @@ class AndroidNotificationTray
return builder.build() 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 { companion object {
private const val REMINDERS_CHANNEL_ID = "REMINDERS" private const val REMINDERS_CHANNEL_ID = "REMINDERS"
fun createAndroidNotificationChannel(context: Context) { fun createAndroidNotificationChannel(context: Context) {

@ -33,6 +33,7 @@ import org.isoron.uhabits.HabitsApplication
import org.isoron.uhabits.R import org.isoron.uhabits.R
import org.isoron.uhabits.activities.AndroidThemeSwitcher import org.isoron.uhabits.activities.AndroidThemeSwitcher
import org.isoron.uhabits.core.models.Habit 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.DarkTheme
import org.isoron.uhabits.core.ui.views.LightTheme import org.isoron.uhabits.core.ui.views.LightTheme
import org.isoron.uhabits.receivers.ReminderController import org.isoron.uhabits.receivers.ReminderController
@ -41,6 +42,7 @@ import java.util.Calendar
class SnoozeDelayPickerActivity : FragmentActivity(), OnItemClickListener { class SnoozeDelayPickerActivity : FragmentActivity(), OnItemClickListener {
private var habit: Habit? = null private var habit: Habit? = null
private var habitGroup: HabitGroup? = null
private var reminderController: ReminderController? = null private var reminderController: ReminderController? = null
private var dialog: AlertDialog? = null private var dialog: AlertDialog? = null
private var androidColor: Int = 0 private var androidColor: Int = 0
@ -58,10 +60,13 @@ class SnoozeDelayPickerActivity : FragmentActivity(), OnItemClickListener {
if (data == null) { if (data == null) {
finish() finish()
} else { } 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() if (habit == null && habitGroup == null) finish()
androidColor = themeSwitcher.currentTheme.color(habit!!.color).toInt() val color = habit?.color ?: habitGroup!!.color
androidColor = themeSwitcher.currentTheme.color(color).toInt()
reminderController = appComponent.reminderController reminderController = appComponent.reminderController
dialog = AlertDialog.Builder(this) dialog = AlertDialog.Builder(this)
.setTitle(R.string.select_snooze_delay) .setTitle(R.string.select_snooze_delay)
@ -87,7 +92,11 @@ class SnoozeDelayPickerActivity : FragmentActivity(), OnItemClickListener {
val calendar = Calendar.getInstance() val calendar = Calendar.getInstance()
val dialog = TimePickerDialog.newInstance( val dialog = TimePickerDialog.newInstance(
{ view: RadialPickerLayout?, hour: Int, minute: Int -> { 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() finish()
}, },
calendar[Calendar.HOUR_OF_DAY], calendar[Calendar.HOUR_OF_DAY],
@ -101,7 +110,11 @@ class SnoozeDelayPickerActivity : FragmentActivity(), OnItemClickListener {
override fun onItemClick(parent: AdapterView<*>?, view: View, position: Int, id: Long) { override fun onItemClick(parent: AdapterView<*>?, view: View, position: Int, id: Long) {
val snoozeValues = resources.getIntArray(R.array.snooze_picker_values) val snoozeValues = resources.getIntArray(R.array.snooze_picker_values)
if (snoozeValues[position] >= 0) { if (snoozeValues[position] >= 0) {
reminderController!!.onSnoozeDelayPicked(habit!!, snoozeValues[position]) if (habit != null) {
reminderController!!.onSnoozeDelayPicked(habit!!, snoozeValues[position])
} else {
reminderController!!.onSnoozeDelayPicked(habitGroup!!, snoozeValues[position])
}
finish() finish()
} else { } else {
showTimePicker() showTimePicker()

@ -23,6 +23,7 @@ import android.content.Intent
import android.net.Uri import android.net.Uri
import org.isoron.uhabits.core.AppScope import org.isoron.uhabits.core.AppScope
import org.isoron.uhabits.core.models.Habit 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.models.Timestamp
import org.isoron.uhabits.core.preferences.Preferences import org.isoron.uhabits.core.preferences.Preferences
import org.isoron.uhabits.core.reminders.ReminderScheduler import org.isoron.uhabits.core.reminders.ReminderScheduler
@ -50,21 +51,45 @@ class ReminderController @Inject constructor(
reminderScheduler.scheduleAll() reminderScheduler.scheduleAll()
} }
fun onShowReminder(
habitGroup: HabitGroup,
timestamp: Timestamp,
reminderTime: Long
) {
notificationTray.show(habitGroup, timestamp, reminderTime)
reminderScheduler.scheduleAll()
}
fun onSnoozePressed(habit: Habit, context: Context) { fun onSnoozePressed(habit: Habit, context: Context) {
showSnoozeDelayPicker(habit, context) showSnoozeDelayPicker(habit, context)
} }
fun onSnoozePressed(habitGroup: HabitGroup, context: Context) {
showSnoozeDelayPicker(habitGroup, context)
}
fun onSnoozeDelayPicked(habit: Habit, delayInMinutes: Int) { fun onSnoozeDelayPicked(habit: Habit, delayInMinutes: Int) {
reminderScheduler.snoozeReminder(habit, delayInMinutes.toLong()) reminderScheduler.snoozeReminder(habit, delayInMinutes.toLong())
notificationTray.cancel(habit) 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) { fun onSnoozeTimePicked(habit: Habit?, hour: Int, minute: Int) {
val time: Long = getUpcomingTimeInMillis(hour, minute) val time: Long = getUpcomingTimeInMillis(hour, minute)
reminderScheduler.scheduleAtTime(habit!!, time) reminderScheduler.scheduleAtTime(habit!!, time)
notificationTray.cancel(habit) 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) { fun onDismiss(habit: Habit) {
if (preferences.shouldMakeNotificationsSticky()) { if (preferences.shouldMakeNotificationsSticky()) {
// This is a workaround to keep sticky notifications non-dismissible in Android 14+. // 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) { private fun showSnoozeDelayPicker(habit: Habit, context: Context) {
context.sendBroadcast(Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS)) context.sendBroadcast(Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS))
val intent = Intent(context, SnoozeDelayPickerActivity::class.java) val intent = Intent(context, SnoozeDelayPickerActivity::class.java)
@ -82,4 +117,12 @@ class ReminderController @Inject constructor(
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent) 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)
}
} }

@ -27,6 +27,7 @@ import android.os.Build.VERSION.SDK_INT
import android.util.Log import android.util.Log
import org.isoron.uhabits.HabitsApplication import org.isoron.uhabits.HabitsApplication
import org.isoron.uhabits.core.models.Habit 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.models.Timestamp
import org.isoron.uhabits.core.utils.DateUtils.Companion.getStartOfTodayWithOffset import org.isoron.uhabits.core.utils.DateUtils.Companion.getStartOfTodayWithOffset
@ -44,52 +45,81 @@ class ReminderReceiver : BroadcastReceiver() {
val app = context.applicationContext as HabitsApplication val app = context.applicationContext as HabitsApplication
val appComponent = app.component val appComponent = app.component
val habits = appComponent.habitList val habits = appComponent.habitList
val habitGroups = appComponent.habitGroupList
val reminderController = appComponent.reminderController val reminderController = appComponent.reminderController
Log.i(TAG, String.format("Received intent: %s", intent.toString())) Log.i(TAG, String.format("Received intent: %s", intent.toString()))
var habit: Habit? = null var habit: Habit? = null
var habitGroup: HabitGroup? = null
var id: Long? = null
var type: String? = null
val today: Long = getStartOfTodayWithOffset() val today: Long = getStartOfTodayWithOffset()
val data = intent.data 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 timestamp = intent.getLongExtra("timestamp", today)
val reminderTime = intent.getLongExtra("reminderTime", today) val reminderTime = intent.getLongExtra("reminderTime", today)
try { try {
when (intent.action) { when (intent.action) {
ACTION_SHOW_REMINDER -> { ACTION_SHOW_REMINDER -> {
if (habit == null) return if (id == null) return
Log.d( Log.d(
"ReminderReceiver", "ReminderReceiver",
String.format( String.format(
"onShowReminder habit=%d timestamp=%d reminderTime=%d", "onShowReminder %s=%d timestamp=%d reminderTime=%d",
habit.id, type,
id,
timestamp, timestamp,
reminderTime reminderTime
) )
) )
reminderController.onShowReminder( if (habit != null) {
habit, reminderController.onShowReminder(
Timestamp(timestamp), habit,
reminderTime Timestamp(timestamp),
) reminderTime
)
} else {
reminderController.onShowReminder(
habitGroup!!,
Timestamp(timestamp),
reminderTime
)
}
} }
ACTION_DISMISS_REMINDER -> { ACTION_DISMISS_REMINDER -> {
if (habit == null) return if (id == null) return
Log.d("ReminderReceiver", String.format("onDismiss habit=%d", habit.id)) Log.d("ReminderReceiver", String.format("onDismiss %s=%d", type, id))
reminderController.onDismiss(habit) if (habit != null) {
reminderController.onDismiss(habit)
} else {
reminderController.onDismiss(habitGroup!!)
}
} }
ACTION_SNOOZE_REMINDER -> { ACTION_SNOOZE_REMINDER -> {
if (habit == null) return if (id == null) return
if (SDK_INT < Build.VERSION_CODES.S) { if (SDK_INT < Build.VERSION_CODES.S) {
Log.d( Log.d(
"ReminderReceiver", "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 { } else {
Log.w( Log.w(
"ReminderReceiver", "ReminderReceiver",
String.format( String.format(
"onSnoozePressed habit=%d, should be deactivated in recent versions.", "onSnoozePressed %s=%d, should be deactivated in recent versions.",
habit.id type,
id
) )
) )
} }

@ -27,15 +27,17 @@ import android.widget.RemoteViews
import org.isoron.uhabits.HabitsApplication import org.isoron.uhabits.HabitsApplication
import org.isoron.uhabits.R import org.isoron.uhabits.R
import org.isoron.uhabits.core.models.Habit 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.HabitList
import org.isoron.uhabits.core.models.HabitNotFoundException import org.isoron.uhabits.core.models.HabitNotFoundException
import org.isoron.uhabits.core.preferences.Preferences import org.isoron.uhabits.core.preferences.Preferences
import org.isoron.uhabits.core.preferences.WidgetPreferences import org.isoron.uhabits.core.preferences.WidgetPreferences
import org.isoron.uhabits.utils.InterfaceUtils.dpToPixels import org.isoron.uhabits.utils.InterfaceUtils.dpToPixels
import java.util.ArrayList
abstract class BaseWidgetProvider : AppWidgetProvider() { abstract class BaseWidgetProvider : AppWidgetProvider() {
private lateinit var habits: HabitList private lateinit var habits: HabitList
private lateinit var habitGroups: HabitGroupList
lateinit var preferences: Preferences lateinit var preferences: Preferences
private set private set
private lateinit var widgetPrefs: WidgetPreferences private lateinit var widgetPrefs: WidgetPreferences
@ -112,12 +114,26 @@ abstract class BaseWidgetProvider : AppWidgetProvider() {
val selectedIds = widgetPrefs.getHabitIdsFromWidgetId(widgetId) val selectedIds = widgetPrefs.getHabitIdsFromWidgetId(widgetId)
val selectedHabits = ArrayList<Habit>(selectedIds.size) val selectedHabits = ArrayList<Habit>(selectedIds.size)
for (id in selectedIds) { for (id in selectedIds) {
val h = habits.getById(id) ?: throw HabitNotFoundException() val h = habits.getById(id) ?: habitGroups.getHabitByID(id)
selectedHabits.add(h) if (h != null) {
selectedHabits.add(h)
}
} }
return selectedHabits return selectedHabits
} }
protected fun getHabitGroupsFromWidgetId(widgetId: Int): List<HabitGroup> {
val selectedIds = widgetPrefs.getHabitIdsFromWidgetId(widgetId)
val selectedHabitGroups = ArrayList<HabitGroup>(selectedIds.size)
for (id in selectedIds) {
val hgr = habitGroups.getById(id)
if (hgr != null) {
selectedHabitGroups.add(hgr)
}
}
return selectedHabitGroups
}
protected abstract fun getWidgetFromId( protected abstract fun getWidgetFromId(
context: Context, context: Context,
id: Int id: Int
@ -159,6 +175,7 @@ abstract class BaseWidgetProvider : AppWidgetProvider() {
private fun updateDependencies(context: Context) { private fun updateDependencies(context: Context) {
val app = context.applicationContext as HabitsApplication val app = context.applicationContext as HabitsApplication
habits = app.component.habitList habits = app.component.habitList
habitGroups = app.component.habitGroupList
preferences = app.component.preferences preferences = app.component.preferences
widgetPrefs = app.component.widgetPreferences widgetPrefs = app.component.widgetPreferences
} }

@ -25,32 +25,51 @@ import android.view.View
import org.isoron.platform.gui.toInt import org.isoron.platform.gui.toInt
import org.isoron.uhabits.activities.common.views.FrequencyChart import org.isoron.uhabits.activities.common.views.FrequencyChart
import org.isoron.uhabits.core.models.Habit 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.core.ui.views.WidgetTheme
import org.isoron.uhabits.widgets.views.GraphWidgetView import org.isoron.uhabits.widgets.views.GraphWidgetView
class FrequencyWidget( class FrequencyWidget private constructor(
context: Context, context: Context,
widgetId: Int, widgetId: Int,
private val habit: Habit, private val habit: Habit?,
private val habitGroup: HabitGroup?,
private val firstWeekday: Int, private val firstWeekday: Int,
stacked: Boolean = false stacked: Boolean
) : BaseWidget(context, widgetId, stacked) { ) : 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 defaultHeight: Int = 200
override val defaultWidth: Int = 200 override val defaultWidth: Int = 200
override fun getOnClickPendingIntent(context: Context): PendingIntent = override fun getOnClickPendingIntent(context: Context): PendingIntent {
pendingIntentFactory.showHabit(habit) return if (habit != null) {
pendingIntentFactory.showHabit(habit)
} else {
pendingIntentFactory.showHabitGroup(habitGroup!!)
}
}
override fun refreshData(v: View) { override fun refreshData(v: View) {
val widgetView = v as GraphWidgetView val widgetView = v as GraphWidgetView
widgetView.setTitle(habit.name) widgetView.setTitle(habit?.name ?: habitGroup!!.name)
widgetView.setBackgroundAlpha(preferedBackgroundAlpha) widgetView.setBackgroundAlpha(preferedBackgroundAlpha)
if (preferedBackgroundAlpha >= 255) widgetView.setShadowAlpha(0x4f) 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 { (widgetView.dataView as FrequencyChart).apply {
setFirstWeekday(firstWeekday) setFirstWeekday(firstWeekday)
setColor(WidgetTheme().color(habit.color).toInt()) setColor(WidgetTheme().color(color).toInt())
setIsNumerical(habit.isNumerical) setIsNumerical(isNumerical)
setFrequency(habit.originalEntries.computeWeekdayFrequency(habit.isNumerical)) setFrequency(frequency)
} }
} }

@ -24,15 +24,29 @@ import android.content.Context
class FrequencyWidgetProvider : BaseWidgetProvider() { class FrequencyWidgetProvider : BaseWidgetProvider() {
override fun getWidgetFromId(context: Context, id: Int): BaseWidget { override fun getWidgetFromId(context: Context, id: Int): BaseWidget {
val habits = getHabitsFromWidgetId(id) val habits = getHabitsFromWidgetId(id)
return if (habits.size == 1) { if (habits.isNotEmpty()) {
FrequencyWidget( return if (habits.size == 1) {
context, FrequencyWidget(
id, context,
habits[0], id,
preferences.firstWeekdayInt habits[0],
) preferences.firstWeekdayInt
)
} else {
StackWidget(context, id, StackWidgetType.FREQUENCY, habits)
}
} else { } 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)
}
} }
} }
} }

@ -25,42 +25,62 @@ import android.view.View
import org.isoron.platform.gui.toInt import org.isoron.platform.gui.toInt
import org.isoron.uhabits.activities.common.views.ScoreChart import org.isoron.uhabits.activities.common.views.ScoreChart
import org.isoron.uhabits.core.models.Habit 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.screens.habits.show.views.ScoreCardPresenter
import org.isoron.uhabits.core.ui.views.WidgetTheme import org.isoron.uhabits.core.ui.views.WidgetTheme
import org.isoron.uhabits.widgets.views.GraphWidgetView import org.isoron.uhabits.widgets.views.GraphWidgetView
class ScoreWidget( class ScoreWidget private constructor(
context: Context, context: Context,
id: Int, id: Int,
private val habit: Habit, private val habit: Habit?,
stacked: Boolean = false private val habitGroup: HabitGroup?,
stacked: Boolean
) : BaseWidget(context, id, stacked) { ) : 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 defaultHeight: Int = 300
override val defaultWidth: Int = 300 override val defaultWidth: Int = 300
override fun getOnClickPendingIntent(context: Context): PendingIntent = override fun getOnClickPendingIntent(context: Context): PendingIntent {
pendingIntentFactory.showHabit(habit) return if (habit != null) {
pendingIntentFactory.showHabit(habit)
} else {
pendingIntentFactory.showHabitGroup(habitGroup!!)
}
}
override fun refreshData(view: View) { override fun refreshData(view: View) {
val viewModel = ScoreCardPresenter.buildState( val viewModel = if (habit != null) {
habit = habit, ScoreCardPresenter.buildState(
firstWeekday = prefs.firstWeekdayInt, habit = habit,
spinnerPosition = prefs.scoreCardSpinnerPosition, firstWeekday = prefs.firstWeekdayInt,
theme = WidgetTheme() spinnerPosition = prefs.scoreCardSpinnerPosition,
) theme = WidgetTheme()
)
} else {
ScoreCardPresenter.buildState(
habitGroup = habitGroup!!,
firstWeekday = prefs.firstWeekdayInt,
spinnerPosition = prefs.scoreCardSpinnerPosition,
theme = WidgetTheme()
)
}
val widgetView = view as GraphWidgetView val widgetView = view as GraphWidgetView
widgetView.setBackgroundAlpha(preferedBackgroundAlpha) widgetView.setBackgroundAlpha(preferedBackgroundAlpha)
if (preferedBackgroundAlpha >= 255) widgetView.setShadowAlpha(0x4f) if (preferedBackgroundAlpha >= 255) widgetView.setShadowAlpha(0x4f)
val color = habit?.color ?: habitGroup!!.color
(widgetView.dataView as ScoreChart).apply { (widgetView.dataView as ScoreChart).apply {
setIsTransparencyEnabled(true) setIsTransparencyEnabled(true)
setBucketSize(viewModel.bucketSize) setBucketSize(viewModel.bucketSize)
setColor(WidgetTheme().color(habit.color).toInt()) setColor(WidgetTheme().color(color).toInt())
setScores(viewModel.scores) setScores(viewModel.scores)
} }
} }
override fun buildView() = override fun buildView() =
GraphWidgetView(context, ScoreChart(context)).apply { GraphWidgetView(context, ScoreChart(context)).apply {
setTitle(habit.name) setTitle(habit?.name ?: habitGroup!!.name)
} }
} }

@ -23,10 +23,19 @@ import android.content.Context
class ScoreWidgetProvider : BaseWidgetProvider() { class ScoreWidgetProvider : BaseWidgetProvider() {
override fun getWidgetFromId(context: Context, id: Int): BaseWidget { override fun getWidgetFromId(context: Context, id: Int): BaseWidget {
val habits = getHabitsFromWidgetId(id) val habits = getHabitsFromWidgetId(id)
return if (habits.size == 1) { if (habits.isNotEmpty()) {
ScoreWidget(context, id, habits[0]) return if (habits.size == 1) {
ScoreWidget(context, id, habits[0])
} else {
StackWidget(context, id, StackWidgetType.SCORE, habits)
}
} else { } 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)
}
} }
} }
} }

@ -28,14 +28,20 @@ import android.view.View
import android.widget.RemoteViews import android.widget.RemoteViews
import org.isoron.platform.utils.StringUtils import org.isoron.platform.utils.StringUtils
import org.isoron.uhabits.core.models.Habit import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.HabitGroup
class StackWidget( class StackWidget private constructor(
context: Context, context: Context,
widgetId: Int, widgetId: Int,
private val widgetType: StackWidgetType, private val widgetType: StackWidgetType,
private val habits: List<Habit>, private val habits: List<Habit>?,
stacked: Boolean = true private val habitGroups: List<HabitGroup>?,
stacked: Boolean
) : BaseWidget(context, widgetId, stacked) { ) : BaseWidget(context, widgetId, stacked) {
constructor(context: Context, widgetId: Int, widgetType: StackWidgetType, habits: List<Habit>, stacked: Boolean = true) : this(context, widgetId, widgetType, habits, null, stacked)
constructor(context: Context, widgetId: Int, widgetType: StackWidgetType, habitGroups: List<HabitGroup>, stacked: Boolean = true, isHabitGroups: Boolean = false) : this(context, widgetId, widgetType, null, habitGroups, stacked)
override val defaultHeight: Int = 0 override val defaultHeight: Int = 0
override val defaultWidth: Int = 0 override val defaultWidth: Int = 0
@ -55,7 +61,11 @@ class StackWidget(
val remoteViews = val remoteViews =
RemoteViews(context.packageName, StackWidgetType.getStackWidgetLayoutId(widgetType)) RemoteViews(context.packageName, StackWidgetType.getStackWidgetLayoutId(widgetType))
val serviceIntent = Intent(context, StackWidgetService::class.java) 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(AppWidgetManager.EXTRA_APPWIDGET_ID, id)
serviceIntent.putExtra(StackWidgetService.WIDGET_TYPE, widgetType.value) serviceIntent.putExtra(StackWidgetService.WIDGET_TYPE, widgetType.value)
@ -73,9 +83,14 @@ class StackWidget(
StackWidgetType.getStackWidgetAdapterViewId(widgetType), StackWidgetType.getStackWidgetAdapterViewId(widgetType),
StackWidgetType.getStackWidgetEmptyViewId(widgetType) StackWidgetType.getStackWidgetEmptyViewId(widgetType)
) )
val pendingIntentTemplate = if (habits != null) {
StackWidgetType.getPendingIntentTemplate(pendingIntentFactory, widgetType, habits)
} else {
StackWidgetType.getPendingIntentTemplate(pendingIntentFactory, widgetType, true)
}
remoteViews.setPendingIntentTemplate( remoteViews.setPendingIntentTemplate(
StackWidgetType.getStackWidgetAdapterViewId(widgetType), StackWidgetType.getStackWidgetAdapterViewId(widgetType),
StackWidgetType.getPendingIntentTemplate(pendingIntentFactory, widgetType, habits) pendingIntentTemplate
) )
return remoteViews return remoteViews
} }

@ -24,7 +24,6 @@ import org.isoron.uhabits.R
import org.isoron.uhabits.core.models.Habit import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.Timestamp import org.isoron.uhabits.core.models.Timestamp
import org.isoron.uhabits.intents.PendingIntentFactory import org.isoron.uhabits.intents.PendingIntentFactory
import java.lang.IllegalStateException
enum class StackWidgetType(val value: Int) { enum class StackWidgetType(val value: Int) {
CHECKMARK(0), FREQUENCY(1), SCORE(2), // habit strength widget 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( fun getIntentFillIn(
factory: PendingIntentFactory, factory: PendingIntentFactory,
widgetType: StackWidgetType, widgetType: StackWidgetType,

@ -25,7 +25,6 @@ import android.appwidget.AppWidgetManager.INVALID_APPWIDGET_ID
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import android.widget.Button
import android.widget.ListView import android.widget.ListView
import android.widget.TextView import android.widget.TextView
import org.isoron.uhabits.HabitsApplication import org.isoron.uhabits.HabitsApplication
@ -44,6 +43,10 @@ class NumericalHabitPickerDialog : HabitPickerDialog() {
override fun getEmptyMessage() = R.string.no_numerical_habits override fun getEmptyMessage() = R.string.no_numerical_habits
} }
class HabitAndGroupPickerDialog : HabitPickerDialog() {
override fun shouldShowGroups(): Boolean = true
}
open class HabitPickerDialog : Activity() { open class HabitPickerDialog : Activity() {
private var widgetId = 0 private var widgetId = 0
@ -52,6 +55,8 @@ open class HabitPickerDialog : Activity() {
protected open fun shouldHideNumerical() = false protected open fun shouldHideNumerical() = false
protected open fun shouldHideBoolean() = false protected open fun shouldHideBoolean() = false
protected open fun shouldShowGroups() = false
protected open fun getEmptyMessage() = R.string.no_habits protected open fun getEmptyMessage() = R.string.no_habits
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -59,6 +64,7 @@ open class HabitPickerDialog : Activity() {
val component = (applicationContext as HabitsApplication).component val component = (applicationContext as HabitsApplication).component
AndroidThemeSwitcher(this, component.preferences).apply() AndroidThemeSwitcher(this, component.preferences).apply()
val habitList = component.habitList val habitList = component.habitList
val habitGroupList = component.habitGroupList
widgetPreferences = component.widgetPreferences widgetPreferences = component.widgetPreferences
widgetUpdater = component.widgetUpdater widgetUpdater = component.widgetUpdater
widgetId = intent.extras?.getInt(EXTRA_APPWIDGET_ID, INVALID_APPWIDGET_ID) ?: 0 widgetId = intent.extras?.getInt(EXTRA_APPWIDGET_ID, INVALID_APPWIDGET_ID) ?: 0
@ -73,6 +79,21 @@ open class HabitPickerDialog : Activity() {
habitNames.add(h.name) 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()) { if (habitNames.isEmpty()) {
setContentView(R.layout.widget_empty_activity) setContentView(R.layout.widget_empty_activity)
findViewById<TextView>(R.id.message).setText(getEmptyMessage()) findViewById<TextView>(R.id.message).setText(getEmptyMessage())
@ -81,7 +102,6 @@ open class HabitPickerDialog : Activity() {
setContentView(R.layout.widget_configure_activity) setContentView(R.layout.widget_configure_activity)
val listView = findViewById<ListView>(R.id.listView) val listView = findViewById<ListView>(R.id.listView)
val saveButton = findViewById<Button>(R.id.buttonSave)
with(listView) { with(listView) {
adapter = ArrayAdapter( adapter = ArrayAdapter(

@ -0,0 +1,163 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/contrast0"
android:fitsSystemWindows="true"
android:orientation="vertical"
tools:context=".activities.habits.edit.EditHabitGroupActivity">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorPrimary"
android:elevation="2dp"
android:gravity="end"
android:minHeight="?attr/actionBarSize"
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
app:title="@string/create_habit_group"
app:titleTextColor="@color/white">
<com.google.android.material.button.MaterialButton
android:id="@+id/buttonSave"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_marginEnd="16dp"
android:text="@string/save"
android:textColor="@color/white"
app:rippleColor="@color/white"
app:strokeColor="@color/white" />
</androidx.appcompat.widget.Toolbar>
</com.google.android.material.appbar.AppBarLayout>
<ScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingTop="8dp"
android:paddingLeft="4dp"
android:paddingRight="4dp">
<!-- Title and color -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:baselineAligned="false">
<!-- Habit Title -->
<FrameLayout
style="@style/FormOuterBox"
android:layout_width="0dp"
android:layout_weight="1">
<LinearLayout style="@style/FormInnerBox">
<TextView
style="@style/FormLabel"
android:text="@string/name" />
<EditText
android:id="@+id/nameInput"
style="@style/FormInput"
android:maxLines="1"
android:inputType="textCapSentences"
android:hint="@string/yes_or_no_short_example"
/>
</LinearLayout>
</FrameLayout>
<!-- Habit Color -->
<FrameLayout
style="@style/FormOuterBox"
android:layout_width="80dp"
android:layout_height="match_parent">
<LinearLayout style="@style/FormInnerBox">
<TextView
style="@style/FormLabel"
android:text="@string/color" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/colorButton"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginLeft="16dp"
android:layout_marginTop="8dp"
android:layout_marginRight="16dp"
android:layout_marginBottom="8dp"
android:backgroundTint="#E23673" />
</LinearLayout>
</FrameLayout>
</LinearLayout>
<!-- Habit Question -->
<FrameLayout style="@style/FormOuterBox">
<LinearLayout style="@style/FormInnerBox">
<TextView
style="@style/FormLabel"
android:text="@string/question" />
<EditText
android:id="@+id/questionInput"
style="@style/FormInput"
android:inputType="textCapSentences|textMultiLine"
android:hint="@string/example_question_boolean"
/>
</LinearLayout>
</FrameLayout>
<!-- Reminder -->
<FrameLayout style="@style/FormOuterBox">
<LinearLayout style="@style/FormInnerBox">
<TextView
style="@style/FormLabel"
android:text="@string/reminder" />
<TextView
style="@style/FormDropdown"
android:id="@+id/reminderTimePicker"
android:text="@string/reminder_off" />
<View
style="@style/FormDivider"
android:id="@+id/reminderDivider"/>
<TextView
style="@style/FormDropdown"
android:id="@+id/reminderDatePicker"
android:text="" />
</LinearLayout>
</FrameLayout>
<!-- Notes -->
<FrameLayout style="@style/FormOuterBox">
<LinearLayout style="@style/FormInnerBox">
<TextView
style="@style/FormLabel"
android:text="@string/notes" />
<EditText
android:id="@+id/notesInput"
style="@style/FormInput"
android:inputType="textCapSentences|textMultiLine"
android:hint="@string/example_notes" />
</LinearLayout>
</FrameLayout>
</LinearLayout>
</ScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

@ -67,6 +67,25 @@
android:text="@string/measurable_example" /> android:text="@string/measurable_example" />
</LinearLayout> </LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/buttonHabitGroup"
style="@style/SelectHabitTypeButton">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/SelectHabitTypeButtonTitle"
android:text="@string/habit_group" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/SelectHabitTypeButtonBody"
android:text="@string/habit_group_example" />
</LinearLayout>
<!-- <LinearLayout--> <!-- <LinearLayout-->
<!-- android:layout_width="match_parent"--> <!-- android:layout_width="match_parent"-->
<!-- android:layout_height="wrap_content"--> <!-- android:layout_height="wrap_content"-->

@ -0,0 +1,101 @@
<!--
~ Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
~
~ 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 <http://www.gnu.org/licenses/>.
-->
<RelativeLayout
android:id="@+id/container"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
style="@style/Toolbar"
app:popupTheme="?toolbarPopupTheme"
android:layout_alignParentTop="true"/>
<ScrollView
android:id="@+id/scrollView"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_below="@id/toolbar"
android:background="?windowBackgroundColor"
android:clipToPadding="false">
<LinearLayout
style="@style/CardList"
android:clipToPadding="false">
<org.isoron.uhabits.activities.habits.show.views.SubtitleCardView
android:id="@+id/subtitleCard"
style="@style/ShowHabit.Subtitle"/>
<org.isoron.uhabits.activities.habits.show.views.NotesCardView
android:id="@+id/notesCard"
style="@style/Card"
android:gravity="center" />
<org.isoron.uhabits.activities.habits.show.views.OverviewCardView
android:id="@+id/overviewCard"
style="@style/Card"
android:paddingTop="12dp"/>
<FrameLayout
android:id="@+id/noSubHabitsCard"
style="@style/Card"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingTop="12dp"
android:visibility="gone">
<TextView
android:id="@+id/textView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/no_sub_habits" />
</FrameLayout>
<org.isoron.uhabits.activities.habits.show.views.TargetCardView
android:id="@+id/targetCard"
style="@style/Card"
android:paddingTop="12dp"/>
<org.isoron.uhabits.activities.habits.show.views.ScoreCardView
android:id="@+id/scoreCard"
style="@style/Card"
android:gravity="center"/>
<org.isoron.uhabits.activities.habits.show.views.BarCardView
android:id="@+id/barCard"
style="@style/Card"
android:gravity="center"/>
<org.isoron.uhabits.activities.habits.show.views.StreakCardView
android:id="@+id/streakCard"
style="@style/Card"/>
<org.isoron.uhabits.activities.habits.show.views.FrequencyCardView
android:id="@+id/frequencyCard"
style="@style/Card"/>
</LinearLayout>
</ScrollView>
</RelativeLayout>

@ -76,7 +76,7 @@
android:text="@string/every_day" android:text="@string/every_day"
android:textColor="?attr/contrast60" android:textColor="?attr/contrast60"
android:layout_marginStart="4dp" android:layout_marginStart="4dp"
android:layout_marginEnd="16dp" android:layout_marginEnd="8dp"
android:textSize="@dimen/smallTextSize" /> android:textSize="@dimen/smallTextSize" />
<TextView <TextView
@ -92,10 +92,31 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:paddingTop="1dp" android:paddingTop="1dp"
android:textColor="?attr/contrast60"
android:text="8:00 AM" android:text="8:00 AM"
android:layout_marginStart="4dp" android:layout_marginStart="4dp"
android:textSize="@dimen/smallTextSize" /> android:textSize="@dimen/smallTextSize" />
</LinearLayout> </LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="2dp"
android:gravity="center_vertical"
android:orientation="horizontal"
tools:visibility="visible">
<TextView
android:id="@+id/skipLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingTop="1dp"
android:textColor="?attr/contrast60"
android:text=""
android:layout_marginStart="4dp"
android:layout_marginEnd="16dp"
android:textSize="@dimen/smallTextSize" />
</LinearLayout>
</merge> </merge>

@ -41,6 +41,16 @@
android:title="@string/unarchive" android:title="@string/unarchive"
app:showAsAction="never"/> app:showAsAction="never"/>
<item
android:id="@+id/action_add_to_group"
android:title="@string/add_to_group"
app:showAsAction="never"/>
<item
android:id="@+id/action_remove_from_group"
android:title="@string/remove_from_group"
app:showAsAction="never"/>
<item <item
android:id="@+id/action_delete" android:id="@+id/action_delete"
android:title="@string/delete" android:title="@string/delete"

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
~
~ 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 <http://www.gnu.org/licenses/>.
-->
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_delete"
android:title="@string/delete"
app:showAsAction="never"/>
<item
android:id="@+id/action_edit_habit_group"
android:icon="?iconEdit"
android:title="@string/edit"
app:showAsAction="ifRoom"/>
</menu>

@ -24,6 +24,9 @@
<string translatable="false" name="fa_arrow_circle_down">&#xf0ab;</string> <string translatable="false" name="fa_arrow_circle_down">&#xf0ab;</string>
<string translatable="false" name="fa_check">&#xf00c;</string> <string translatable="false" name="fa_check">&#xf00c;</string>
<string translatable="false" name="fa_times">&#xf00d;</string> <string translatable="false" name="fa_times">&#xf00d;</string>
<string translatable="false" name="fa_plus">&#xf067;</string>
<string translatable="false" name="fa_angle_left">&#xf053;</string>
<string translatable="false" name="fa_angle_down">&#xf078;</string>
<string translatable="false" name="fa_skipped">&#xf068;</string> <string translatable="false" name="fa_skipped">&#xf068;</string>
<string translatable="false" name="fa_bell_o">&#xf0f3;</string> <string translatable="false" name="fa_bell_o">&#xf0f3;</string>
<string translatable="false" name="fa_calendar">&#xf073;</string> <string translatable="false" name="fa_calendar">&#xf073;</string>

@ -29,6 +29,8 @@
<string name="delete">Delete</string> <string name="delete">Delete</string>
<string name="archive">Archive</string> <string name="archive">Archive</string>
<string name="unarchive">Unarchive</string> <string name="unarchive">Unarchive</string>
<string name="remove_from_group">Remove from group</string>
<string name="add_to_group">Add to group</string>
<string name="add_habit">Add habit</string> <string name="add_habit">Add habit</string>
<string name="color_picker_default_title">Change color</string> <string name="color_picker_default_title">Change color</string>
<string name="toast_habit_created">Habit created</string> <string name="toast_habit_created">Habit created</string>
@ -62,6 +64,8 @@
<string name="reminder_off">Off</string> <string name="reminder_off">Off</string>
<string name="create_habit">Create habit</string> <string name="create_habit">Create habit</string>
<string name="edit_habit">Edit habit</string> <string name="edit_habit">Edit habit</string>
<string name="create_habit_group">Create habit group</string>
<string name="edit_habit_group">Edit habit group</string>
<string name="check">Check</string> <string name="check">Check</string>
<string name="snooze">Later</string> <string name="snooze">Later</string>
<string name="intro_title_1">Welcome</string> <string name="intro_title_1">Welcome</string>
@ -206,6 +210,8 @@
<string name="yes_or_no_example">e.g. Did you wake up early today? Did you exercise? Did you play chess?</string> <string name="yes_or_no_example">e.g. Did you wake up early today? Did you exercise? Did you play chess?</string>
<string name="measurable">Measurable</string> <string name="measurable">Measurable</string>
<string name="measurable_example">e.g. How many miles did you run today? How many pages did you read?</string> <string name="measurable_example">e.g. How many miles did you run today? How many pages did you read?</string>
<string name="habit_group">Habit Group</string>
<string name="habit_group_example">A group to organize similar habits together. E.g. Exercise: Running, Cycling, Gym, etc.</string>
<string name="x_times_per_week">%d times per week</string> <string name="x_times_per_week">%d times per week</string>
<string name="x_times_per_month">%d times per month</string> <string name="x_times_per_month">%d times per month</string>
<string name="x_times_per_y_days">%d times in %d days</string> <string name="x_times_per_y_days">%d times in %d days</string>
@ -233,4 +239,6 @@
<string name="activity_not_found">No app was found to support this action</string> <string name="activity_not_found">No app was found to support this action</string>
<string name="pref_midnight_delay_title">Extend day a few hours past midnight</string> <string name="pref_midnight_delay_title">Extend day a few hours past midnight</string>
<string name="pref_midnight_delay_description">Wait until 3:00 AM to show a new day. Useful if you typically go to sleep after midnight. Requires app restart.</string> <string name="pref_midnight_delay_description">Wait until 3:00 AM to show a new day. Useful if you typically go to sleep after midnight. Requires app restart.</string>
<string name="no_sub_habits">Group contains no habits.</string>
<string name="no_habit_groups">No habit groups</string>
</resources> </resources>

@ -27,7 +27,7 @@
android:previewImage="@drawable/widget_preview_frequency" android:previewImage="@drawable/widget_preview_frequency"
android:resizeMode="vertical|horizontal" android:resizeMode="vertical|horizontal"
android:updatePeriodMillis="3600000" android:updatePeriodMillis="3600000"
android:configure="org.isoron.uhabits.widgets.activities.HabitPickerDialog" android:configure="org.isoron.uhabits.widgets.activities.HabitAndGroupPickerDialog"
android:widgetCategory="home_screen"> android:widgetCategory="home_screen">
</appwidget-provider> </appwidget-provider>

@ -27,7 +27,7 @@
android:previewImage="@drawable/widget_preview_score" android:previewImage="@drawable/widget_preview_score"
android:resizeMode="vertical|horizontal" android:resizeMode="vertical|horizontal"
android:updatePeriodMillis="3600000" android:updatePeriodMillis="3600000"
android:configure="org.isoron.uhabits.widgets.activities.HabitPickerDialog" android:configure="org.isoron.uhabits.widgets.activities.HabitAndGroupPickerDialog"
android:widgetCategory="home_screen"> android:widgetCategory="home_screen">
</appwidget-provider> </appwidget-provider>

@ -0,0 +1,2 @@
alter table Habits add column skip_days integer not null default 0
alter table Habits add column skip_days_list integer not null default 0

@ -20,4 +20,4 @@ package org.isoron.uhabits.core
const val DATABASE_FILENAME = "uhabits.db" const val DATABASE_FILENAME = "uhabits.db"
const val DATABASE_VERSION = 25 const val DATABASE_VERSION = 26

@ -0,0 +1,43 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.commands
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.HabitGroup
import org.isoron.uhabits.core.models.HabitList
data class AddToGroupCommand(
val habitList: HabitList,
val hgr: HabitGroup,
val selected: List<Habit>
) : Command {
override fun run() {
for (habit in selected) {
val entries = habit.originalEntries.getKnown()
val oldGroup = habit.group
(oldGroup?.habitList ?: habitList).remove(habit)
habit.groupId = hgr.id
habit.groupUUID = hgr.uuid
habit.group = hgr
hgr.habitList.add(habit)
entries.forEach { habit.originalEntries.add(it) }
habit.observable.notifyListeners()
}
}
}

@ -0,0 +1,19 @@
package org.isoron.uhabits.core.commands
import org.isoron.uhabits.core.models.HabitGroup
import org.isoron.uhabits.core.models.HabitGroupList
data class ArchiveHabitGroupsCommand(
val habitGroupList: HabitGroupList,
val selected: List<HabitGroup>
) : Command {
override fun run() {
for (hgr in selected) {
hgr.isArchived = true
for (h in hgr.habitList) {
h.isArchived = true
}
}
habitGroupList.update(selected)
}
}

@ -0,0 +1,16 @@
package org.isoron.uhabits.core.commands
import org.isoron.uhabits.core.models.HabitGroup
import org.isoron.uhabits.core.models.HabitGroupList
import org.isoron.uhabits.core.models.PaletteColor
data class ChangeHabitGroupColorCommand(
val habitGroupList: HabitGroupList,
val selected: List<HabitGroup>,
val newColor: PaletteColor
) : Command {
override fun run() {
for (hgr in selected) hgr.color = newColor
habitGroupList.update(selected)
}
}

@ -0,0 +1,18 @@
package org.isoron.uhabits.core.commands
import org.isoron.uhabits.core.models.HabitGroup
import org.isoron.uhabits.core.models.HabitGroupList
import org.isoron.uhabits.core.models.ModelFactory
data class CreateHabitGroupCommand(
val modelFactory: ModelFactory,
val habitGroupList: HabitGroupList,
val model: HabitGroup
) : Command {
override fun run() {
val habitGroup = modelFactory.buildHabitGroup()
habitGroup.copyFrom(model)
habitGroupList.add(habitGroup)
habitGroup.recompute()
}
}

@ -0,0 +1,13 @@
package org.isoron.uhabits.core.commands
import org.isoron.uhabits.core.models.HabitGroup
import org.isoron.uhabits.core.models.HabitGroupList
data class DeleteHabitGroupsCommand(
val habitGroupList: HabitGroupList,
val selected: List<HabitGroup>
) : Command {
override fun run() {
for (hgr in selected) habitGroupList.remove(hgr)
}
}

@ -19,6 +19,7 @@
package org.isoron.uhabits.core.commands package org.isoron.uhabits.core.commands
import org.isoron.uhabits.core.models.Habit 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.HabitList
data class DeleteHabitsCommand( data class DeleteHabitsCommand(
@ -26,6 +27,13 @@ data class DeleteHabitsCommand(
val selected: List<Habit> val selected: List<Habit>
) : Command { ) : Command {
override fun run() { override fun run() {
for (h in selected) habitList.remove(h) for (h in selected) {
if (!h.isSubHabit()) {
habitList.remove(h)
} else {
val list = (h.group as HabitGroup).habitList
list.remove(h)
}
}
} }
} }

@ -0,0 +1,20 @@
package org.isoron.uhabits.core.commands
import org.isoron.uhabits.core.models.HabitGroup
import org.isoron.uhabits.core.models.HabitGroupList
import org.isoron.uhabits.core.models.HabitNotFoundException
data class EditHabitGroupCommand(
val habitGroupList: HabitGroupList,
val habitGroupId: Long,
val modified: HabitGroup
) : Command {
override fun run() {
val habitGroup = habitGroupList.getById(habitGroupId) ?: throw HabitNotFoundException()
habitGroup.copyFrom(modified)
habitGroupList.update(habitGroup)
habitGroup.observable.notifyListeners()
habitGroup.recompute()
habitGroupList.resort()
}
}

@ -0,0 +1,34 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.commands
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.HabitGroupList
data class RefreshParentGroupCommand(
val habit: Habit,
val habitGroupList: HabitGroupList
) : Command {
override fun run() {
if (!habit.isSubHabit()) return
val hgr = habit.group
hgr!!.recompute()
habitGroupList.resort()
}
}

@ -0,0 +1,41 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.commands
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.HabitList
data class RemoveFromGroupCommand(
val habitList: HabitList,
val selected: List<Habit>
) : Command {
override fun run() {
for (habit in selected) {
val hgr = habit.group!!
val entries = habit.originalEntries.getKnown()
hgr.habitList.remove(habit)
habit.groupId = null
habit.group = null
habit.groupUUID = null
habitList.add(habit)
entries.forEach { habit.originalEntries.add(it) }
habit.observable.notifyListeners()
}
}
}

@ -0,0 +1,19 @@
package org.isoron.uhabits.core.commands
import org.isoron.uhabits.core.models.HabitGroup
import org.isoron.uhabits.core.models.HabitGroupList
data class UnarchiveHabitGroupsCommand(
val habitGroupList: HabitGroupList,
val selected: List<HabitGroup>
) : Command {
override fun run() {
for (hgr in selected) {
hgr.isArchived = false
for (h in hgr.habitList) {
h.isArchived = false
}
}
habitGroupList.update(selected)
}
}

@ -24,7 +24,6 @@ import java.sql.Connection
import java.sql.PreparedStatement import java.sql.PreparedStatement
import java.sql.SQLException import java.sql.SQLException
import java.sql.Types import java.sql.Types
import java.util.ArrayList
class JdbcDatabase(private val connection: Connection) : Database { class JdbcDatabase(private val connection: Connection) : Database {
private var transactionSuccessful = false private var transactionSuccessful = false
@ -51,6 +50,14 @@ class JdbcDatabase(private val connection: Connection) : Database {
valuesStr.add(value.toString()) valuesStr.add(value.toString())
} }
valuesStr.addAll(listOf(*params)) valuesStr.addAll(listOf(*params))
if (tableName == "habits") {
val groupIdx = fields.indexOf("group_id=?")
if (valuesStr[groupIdx] == "null") {
valuesStr.removeAt(groupIdx)
fields.removeAt(groupIdx)
}
}
val query = String.format( val query = String.format(
"update %s set %s where %s", "update %s set %s where %s",
tableName, tableName,

@ -22,8 +22,6 @@ import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.tuple.ImmutablePair import org.apache.commons.lang3.tuple.ImmutablePair
import org.apache.commons.lang3.tuple.Pair import org.apache.commons.lang3.tuple.Pair
import java.lang.reflect.Field import java.lang.reflect.Field
import java.util.ArrayList
import java.util.HashMap
import java.util.LinkedList import java.util.LinkedList
class Repository<T>( class Repository<T>(
@ -130,6 +128,26 @@ class Repository<T>(
} }
} }
/**
* Gets the shared id for tables that need to maintain unique ids across tables
* like habits and habit groups
*/
fun getNextAvailableId(name: String): Long {
val query = "SELECT next_id FROM SharedIds WHERE name = ?"
val cursor = db.query(query, name)
val nextId: Long
cursor.use { c ->
if (cursor.moveToNext()) {
nextId = cursor.getLong(0) ?: throw IllegalStateException("Cannot fetch shared ID for $name")
execSQL("UPDATE SharedIds SET next_id = next_id + 1 WHERE name = ?", name)
} else {
throw IllegalStateException("Cannot fetch shared ID for $name")
}
}
return nextId
}
private fun cursorToMultipleRecords(c: Cursor): List<T> { private fun cursorToMultipleRecords(c: Cursor): List<T> {
val records: MutableList<T> = LinkedList() val records: MutableList<T> = LinkedList()
while (c.moveToNext()) records.add(cursorToSingleRecord(c)) while (c.moveToNext()) records.add(cursorToSingleRecord(c))

@ -22,15 +22,19 @@ import org.isoron.uhabits.core.AppScope
import org.isoron.uhabits.core.DATABASE_VERSION import org.isoron.uhabits.core.DATABASE_VERSION
import org.isoron.uhabits.core.commands.CommandRunner import org.isoron.uhabits.core.commands.CommandRunner
import org.isoron.uhabits.core.commands.CreateHabitCommand import org.isoron.uhabits.core.commands.CreateHabitCommand
import org.isoron.uhabits.core.commands.CreateHabitGroupCommand
import org.isoron.uhabits.core.commands.EditHabitCommand import org.isoron.uhabits.core.commands.EditHabitCommand
import org.isoron.uhabits.core.commands.EditHabitGroupCommand
import org.isoron.uhabits.core.database.DatabaseOpener import org.isoron.uhabits.core.database.DatabaseOpener
import org.isoron.uhabits.core.database.MigrationHelper import org.isoron.uhabits.core.database.MigrationHelper
import org.isoron.uhabits.core.database.Repository import org.isoron.uhabits.core.database.Repository
import org.isoron.uhabits.core.models.Entry import org.isoron.uhabits.core.models.Entry
import org.isoron.uhabits.core.models.HabitGroupList
import org.isoron.uhabits.core.models.HabitList import org.isoron.uhabits.core.models.HabitList
import org.isoron.uhabits.core.models.ModelFactory import org.isoron.uhabits.core.models.ModelFactory
import org.isoron.uhabits.core.models.Timestamp import org.isoron.uhabits.core.models.Timestamp
import org.isoron.uhabits.core.models.sqlite.records.EntryRecord import org.isoron.uhabits.core.models.sqlite.records.EntryRecord
import org.isoron.uhabits.core.models.sqlite.records.HabitGroupRecord
import org.isoron.uhabits.core.models.sqlite.records.HabitRecord import org.isoron.uhabits.core.models.sqlite.records.HabitRecord
import org.isoron.uhabits.core.utils.isSQLite3File import org.isoron.uhabits.core.utils.isSQLite3File
import java.io.File import java.io.File
@ -42,6 +46,7 @@ import javax.inject.Inject
class LoopDBImporter class LoopDBImporter
@Inject constructor( @Inject constructor(
@AppScope val habitList: HabitList, @AppScope val habitList: HabitList,
@AppScope val habitGroupList: HabitGroupList,
@AppScope val modelFactory: ModelFactory, @AppScope val modelFactory: ModelFactory,
@AppScope val opener: DatabaseOpener, @AppScope val opener: DatabaseOpener,
@AppScope val runner: CommandRunner, @AppScope val runner: CommandRunner,
@ -74,26 +79,66 @@ class LoopDBImporter
helper.migrateTo(DATABASE_VERSION) helper.migrateTo(DATABASE_VERSION)
val habitsRepository = Repository(HabitRecord::class.java, db) val habitsRepository = Repository(HabitRecord::class.java, db)
val habitGroupsRepository = Repository(HabitGroupRecord::class.java, db)
val entryRepository = Repository(EntryRecord::class.java, db) val entryRepository = Repository(EntryRecord::class.java, db)
for (groupRecord in habitGroupsRepository.findAll("order by position")) {
var hgr = habitGroupList.getByUUID(groupRecord.uuid)
if (hgr == null) {
hgr = modelFactory.buildHabitGroup()
groupRecord.id = null
groupRecord.copyTo(hgr)
CreateHabitGroupCommand(modelFactory, habitGroupList, hgr).run()
} else {
val modified = modelFactory.buildHabitGroup()
groupRecord.id = hgr.id
groupRecord.copyTo(modified)
EditHabitGroupCommand(habitGroupList, hgr.id!!, modified).run()
}
}
for (habitRecord in habitsRepository.findAll("order by position")) { for (habitRecord in habitsRepository.findAll("order by position")) {
var habit = habitList.getByUUID(habitRecord.uuid) var habit = habitList.getByUUID(habitRecord.uuid) ?: habitGroupList.getHabitByUUID(habitRecord.uuid)
val entryRecords = entryRepository.findAll("where habit = ?", habitRecord.id.toString()) val entryRecords = entryRepository.findAll("where habit = ?", habitRecord.id.toString())
if (habit == null) { if (habit == null) {
habit = modelFactory.buildHabit() habit = modelFactory.buildHabit()
habitRecord.id = null habitRecord.id = null
habitRecord.copyTo(habit) habitRecord.copyTo(habit)
CreateHabitCommand(modelFactory, habitList, habit).run() val list = if (habit.groupId != null) {
val hgr = habitGroupList.getByUUID(habit.groupUUID)
if (hgr != null) {
habit.group = hgr
habit.groupId = hgr.id
hgr.habitList
} else {
habit.group = null
habit.groupId = null
habit.groupUUID = null
habitList
}
} else {
habitList
}
CreateHabitCommand(modelFactory, list, habit).run()
} else { } else {
val modified = modelFactory.buildHabit() val modified = modelFactory.buildHabit()
habitRecord.id = habit.id habitRecord.id = habit.id
habitRecord.copyTo(modified) habitRecord.copyTo(modified)
EditHabitCommand(habitList, habit.id!!, modified).run() val list = if (habit.group != null) {
modified.group = habit.group
modified.groupId = habit.groupId
modified.groupUUID = habit.groupUUID
habit.group!!.habitList
} else {
habitList
}
EditHabitCommand(list, habit.id!!, modified).run()
} }
// Reload saved version of the habit // Reload saved version of the habit
habit = habitList.getByUUID(habitRecord.uuid)!! habit = habitList.getByUUID(habitRecord.uuid) ?: habitGroupList.getHabitByUUID(habitRecord.uuid)!!
val entries = habit.originalEntries val entries = habit.originalEntries
// Import entries // Import entries
@ -106,6 +151,9 @@ class LoopDBImporter
} }
habit.recompute() habit.recompute()
} }
habitGroupList.forEach { it.recompute() }
habitGroupList.resort()
habitList.resort() habitList.resort()
db.close() db.close()
} }

@ -24,12 +24,12 @@ import org.isoron.uhabits.core.models.Entry.Companion.UNKNOWN
import org.isoron.uhabits.core.models.Entry.Companion.YES_AUTO import org.isoron.uhabits.core.models.Entry.Companion.YES_AUTO
import org.isoron.uhabits.core.models.Entry.Companion.YES_MANUAL import org.isoron.uhabits.core.models.Entry.Companion.YES_MANUAL
import org.isoron.uhabits.core.utils.DateUtils import org.isoron.uhabits.core.utils.DateUtils
import java.util.ArrayList
import java.util.Calendar import java.util.Calendar
import javax.annotation.concurrent.ThreadSafe import javax.annotation.concurrent.ThreadSafe
import kotlin.collections.set import kotlin.collections.set
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
import kotlin.math.roundToInt
@ThreadSafe @ThreadSafe
open class EntryList { open class EntryList {
@ -151,6 +151,22 @@ open class EntryList {
return map return map
} }
@Synchronized fun normalizeEntries(
isNumerical: Boolean,
frequency: Frequency,
targetValue: Double
): EntryList {
val entries = getKnown()
val normalized = EntryList()
val dailyTarget = frequency.toDouble() * (if (isNumerical) targetValue else 0.001)
for (entry in entries) {
if (!isNumerical && entry.value != YES_MANUAL) continue
val newValue = (entry.value.toDouble() / dailyTarget).roundToInt()
normalized.add(Entry(entry.timestamp, newValue))
}
return normalized
}
data class Interval(val begin: Timestamp, val center: Timestamp, val end: Timestamp) { data class Interval(val begin: Timestamp, val center: Timestamp, val end: Timestamp) {
val length: Int val length: Int
get() = begin.daysUntil(end) + 1 get() = begin.daysUntil(end) + 1
@ -318,6 +334,16 @@ fun List<Entry>.groupedSum(
} }
} }
fun List<Entry>.groupedSum(): List<Entry> {
return this
.groupBy { entry -> entry.timestamp }
.entries.map { (timestamp, entries) ->
Entry(timestamp, entries.sumOf { it.value })
}.sortedBy { (timestamp, _) ->
-timestamp.unixTime
}
}
/** /**
* Counts the number of days with vaLue SKIP in the given period. * Counts the number of days with vaLue SKIP in the given period.
*/ */

@ -39,12 +39,15 @@ data class Habit(
val computedEntries: EntryList, val computedEntries: EntryList,
val originalEntries: EntryList, val originalEntries: EntryList,
val scores: ScoreList, val scores: ScoreList,
val streaks: StreakList val streaks: StreakList,
var groupId: Long? = null,
var groupUUID: String? = null
) { ) {
init { init {
if (uuid == null) this.uuid = UUID.randomUUID().toString().replace("-", "") if (uuid == null) this.uuid = UUID.randomUUID().toString().replace("-", "")
} }
var group: HabitGroup? = null
var observable = ModelObservable() var observable = ModelObservable()
val isNumerical: Boolean val isNumerical: Boolean
@ -53,6 +56,10 @@ data class Habit(
val uriString: String val uriString: String
get() = "content://org.isoron.uhabits/habit/$id" get() = "content://org.isoron.uhabits/habit/$id"
var collapsed = false
fun isSubHabit(): Boolean = groupUUID != null
fun hasReminder(): Boolean = reminder != null fun hasReminder(): Boolean = reminder != null
fun isCompletedToday(): Boolean { fun isCompletedToday(): Boolean {
@ -104,6 +111,10 @@ data class Habit(
) )
} }
fun firstEntryDate(): Timestamp {
return computedEntries.getKnown().lastOrNull()?.timestamp ?: DateUtils.getTodayWithOffset()
}
fun copyFrom(other: Habit) { fun copyFrom(other: Habit) {
this.color = other.color this.color = other.color
this.description = other.description this.description = other.description
@ -119,6 +130,9 @@ data class Habit(
this.type = other.type this.type = other.type
this.unit = other.unit this.unit = other.unit
this.uuid = other.uuid this.uuid = other.uuid
this.group = other.group
this.groupId = other.groupId
this.groupUUID = other.groupUUID
} }
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
@ -139,6 +153,8 @@ data class Habit(
if (type != other.type) return false if (type != other.type) return false
if (unit != other.unit) return false if (unit != other.unit) return false
if (uuid != other.uuid) return false if (uuid != other.uuid) return false
if (groupId != other.groupId) return false
if (groupUUID != other.groupUUID) return false
return true return true
} }
@ -158,6 +174,8 @@ data class Habit(
result = 31 * result + type.value result = 31 * result + type.value
result = 31 * result + unit.hashCode() result = 31 * result + unit.hashCode()
result = 31 * result + (uuid?.hashCode() ?: 0) result = 31 * result + (uuid?.hashCode() ?: 0)
result = 31 * result + (groupId?.hashCode() ?: 0)
result = 31 * result + (groupUUID?.hashCode() ?: 0)
return result return result
} }
} }

@ -0,0 +1,148 @@
package org.isoron.uhabits.core.models
import org.isoron.uhabits.core.utils.DateUtils
import java.util.UUID
data class HabitGroup(
var color: PaletteColor = PaletteColor(8),
var description: String = "",
var id: Long? = null,
var isArchived: Boolean = false,
var name: String = "",
var position: Int = 0,
var question: String = "",
var reminder: Reminder? = null,
var uuid: String? = null,
var habitList: HabitList,
val scores: ScoreList,
val streaks: StreakList
) {
constructor(
parent: HabitGroup,
matcher: HabitMatcher
) : this(
parent.color,
parent.description,
parent.id,
parent.isArchived,
parent.name,
parent.position,
parent.question,
parent.reminder,
parent.uuid,
parent.habitList.getFiltered(matcher),
parent.scores,
parent.streaks
) {
this.collapsed = parent.collapsed
this.parent = parent
}
init {
if (uuid == null) this.uuid = UUID.randomUUID().toString().replace("-", "")
}
var observable = ModelObservable()
var parent: HabitGroup? = null
val uriString: String
get() = "content://org.isoron.uhabits/habitgroup/$id"
var collapsed = false
set(value) {
field = value
habitList.collapsed = value
if (parent != null) parent!!.collapsed = value
}
fun hasReminder(): Boolean = reminder != null
fun isCompletedToday(): Boolean {
if (habitList.isEmpty) return false
return habitList.all { it.isCompletedToday() }
}
fun isEnteredToday(): Boolean {
if (habitList.isEmpty) return false
return habitList.all { it.isEnteredToday() }
}
fun firstEntryDate(): Timestamp {
val today = DateUtils.getTodayWithOffset()
var earliest = today
for (h in habitList) {
val first = h.firstEntryDate()
if (earliest.isNewerThan(first)) earliest = first
}
return earliest
}
fun recompute() {
for (h in habitList) h.recompute()
val today = DateUtils.getTodayWithOffset()
val to = today.plus(30)
var from = firstEntryDate()
if (from.isNewerThan(to)) from = to
scores.combineFrom(
habitList = habitList,
from = from,
to = to
)
streaks.combineFrom(
habitList = habitList,
from = from,
to = to
)
}
fun copyFrom(other: HabitGroup) {
this.color = other.color
this.description = other.description
// this.id should not be copied
this.isArchived = other.isArchived
this.name = other.name
this.position = other.position
this.question = other.question
this.reminder = other.reminder
this.uuid = other.uuid
this.habitList.groupId = this.id
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is HabitGroup) return false
if (color != other.color) return false
if (description != other.description) return false
if (id != other.id) return false
if (isArchived != other.isArchived) return false
if (name != other.name) return false
if (position != other.position) return false
if (question != other.question) return false
if (reminder != other.reminder) return false
if (uuid != other.uuid) return false
return true
}
override fun hashCode(): Int {
var result = color.hashCode()
result = 31 * result + description.hashCode()
result = 31 * result + (id?.hashCode() ?: 0)
result = 31 * result + isArchived.hashCode()
result = 31 * result + name.hashCode()
result = 31 * result + position
result = 31 * result + question.hashCode()
result = 31 * result + (reminder?.hashCode() ?: 0)
result = 31 * result + (uuid?.hashCode() ?: 0)
return result
}
fun getHabitByUUID(uuid: String?): Habit? =
habitList.getByUUID(uuid)
}

@ -0,0 +1,221 @@
package org.isoron.uhabits.core.models
import com.opencsv.CSVWriter
import org.isoron.uhabits.core.models.HabitList.Order
import java.io.IOException
import java.io.Writer
import java.util.LinkedList
import javax.annotation.concurrent.ThreadSafe
/**
* An ordered collection of [HabitGroup]s.
*/
@ThreadSafe
abstract class HabitGroupList : Iterable<HabitGroup> {
val observable: ModelObservable
@JvmField
protected val filter: HabitMatcher
/**
* Creates a new HabitGroupList.
*
* Depending on the implementation, this list can either be empty or be
* populated by some pre-existing habitgroups, for example, from a certain
* database.
*/
constructor() {
observable = ModelObservable()
filter = HabitMatcher(isArchivedAllowed = true)
}
protected constructor(filter: HabitMatcher) {
observable = ModelObservable()
this.filter = filter
}
/**
* Inserts a new habit group in the list.
*
* If the id of the habit group is null, the list will assign it a new id, which
* is guaranteed to be unique in the scope of the list. If id is not null,
* the caller should make sure that the list does not already contain
* another habit group with same id, otherwise a RuntimeException will be thrown.
*
* @param habitGroup the habit to be inserted
* @throws IllegalArgumentException if the habit is already on the list.
*/
@Throws(IllegalArgumentException::class)
abstract fun add(habitGroup: HabitGroup)
/**
* Returns the habit group with specified id.
*
* @param id the id of the habit group
* @return the habit group, or null if none exist
*/
abstract fun getById(id: Long): HabitGroup?
/**
* Returns the habit group with specified UUID.
*
* @param uuid the UUID of the habit group
* @return the habit group, or null if none exist
*/
abstract fun getByUUID(uuid: String?): HabitGroup?
/**
* Returns the habit with the specified UUID which is
* present in any of the habit groups within this habit group list.
*/
fun getHabitByUUID(uuid: String?): Habit? {
for (hgr in this) {
val habit = hgr.getHabitByUUID(uuid)
if (habit != null) {
return habit
}
}
return null
}
/**
* Returns the habit with the specified UUID which is
* present in any of the habit groups within this habit group list.
*/
fun getHabitByID(id: Long): Habit? {
for (hgr in this) {
val habit = hgr.habitList.getById(id)
if (habit != null) {
return habit
}
}
return null
}
/**
* Returns the habit group that occupies a certain position.
*
* @param position the position of the desired habit group
* @return the habit group at that position
* @throws IndexOutOfBoundsException when the position is invalid
*/
abstract fun getByPosition(position: Int): HabitGroup
/**
* Returns the list of habit groups that match a given condition.
*
* @param matcher the matcher that checks the condition
* @return the list of matching habit groups
*/
abstract fun getFiltered(matcher: HabitMatcher?): HabitGroupList
abstract var primaryOrder: Order
abstract var secondaryOrder: Order
/**
* Returns the index of the given habit group in the list, or -1 if the list does
* not contain the habit group.
*
* @param h the habit group
* @return the index of the habit group, or -1 if not in the list
*/
abstract fun indexOf(h: HabitGroup): Int
val isEmpty: Boolean
get() = size() == 0
/**
* Removes the given habit group from the list.
*
* If the given habit group is not in the list, does nothing.
*
* @param h the habit group to be removed.
*/
abstract fun remove(h: HabitGroup)
/**
* Removes all the habit groups from the list.
*/
open fun removeAll() {
val copy: MutableList<HabitGroup> = LinkedList()
for (h in this) copy.add(h)
for (h in copy) remove(h)
observable.notifyListeners()
}
/**
* Changes the position of a habit group in the list.
*
* @param from the habit group that should be moved
* @param to the habit group that currently occupies the desired position
*/
abstract fun reorder(from: HabitGroup, to: HabitGroup)
open fun repair() {}
/**
* Returns the number of habit groups in this list.
*
* @return number of habit groups
*/
abstract fun size(): Int
/**
* Notifies the list that a certain list of habit groups has been modified.
*
* Depending on the implementation, this operation might trigger a write to
* disk, or do nothing at all. To make sure that the habit groups get persisted,
* this operation must be called.
*
* @param habitGroups the list of habit groups that have been modified.
*/
abstract fun update(habitGroups: List<HabitGroup>)
/**
* Notifies the list that a certain habit group has been modified.
*
* See [.update] for more details.
*
* @param habitGroup the habit groups that has been modified.
*/
fun update(habitGroup: HabitGroup) {
update(listOf(habitGroup))
}
/**
* For each habit group, point all the habits in it
* to the group it is contained in
* */
abstract fun attachHabitsToGroups()
/**
* Writes the list of habit groups to the given writer, in CSV format. There is
* one line for each habit group, containing the fields name, description,
* , and color. The color is written in HTML format (#000000).
*
* @param out the writer that will receive the result
* @throws IOException if write operations fail
*/
@Throws(IOException::class)
fun writeCSV(out: Writer) {
val header = arrayOf(
"Position",
"Name",
"Question",
"Description",
"Color"
)
val csv = CSVWriter(out)
csv.writeNext(header, false)
for (hgr in this) {
val cols = arrayOf(
String.format("%03d", indexOf(hgr) + 1),
hgr.name,
hgr.question,
hgr.description,
hgr.color.toCsvColor()
)
csv.writeNext(cols, false)
}
csv.close()
}
abstract fun resort()
}

@ -34,6 +34,10 @@ abstract class HabitList : Iterable<Habit> {
@JvmField @JvmField
protected val filter: HabitMatcher protected val filter: HabitMatcher
abstract var collapsed: Boolean
var groupId: Long? = null
/** /**
* Creates a new HabitList. * Creates a new HabitList.
* *
@ -65,6 +69,20 @@ abstract class HabitList : Iterable<Habit> {
@Throws(IllegalArgumentException::class) @Throws(IllegalArgumentException::class)
abstract fun add(habit: Habit) abstract fun add(habit: Habit)
/**
* Inserts a new habit in the list at the given position.
*
* If the id of the habit is null, the list will assign it a new id, which
* is guaranteed to be unique in the scope of the list. If id is not null,
* the caller should make sure that the list does not already contain
* another habit with same id, otherwise a RuntimeException will be thrown.
*
* @param habit the habit to be inserted
* @throws IllegalArgumentException if the habit is already on the list.
*/
@Throws(IllegalArgumentException::class)
abstract fun add(position: Int, habit: Habit)
/** /**
* Returns the habit with specified id. * Returns the habit with specified id.
* *
@ -120,6 +138,13 @@ abstract class HabitList : Iterable<Habit> {
*/ */
abstract fun remove(h: Habit) abstract fun remove(h: Habit)
/**
* Removes the reference to the habit from the list at the given position.
*
* Does not affect the repository or records
*/
abstract fun removeAt(position: Int)
/** /**
* Removes all the habits from the list. * Removes all the habits from the list.
*/ */

@ -29,6 +29,15 @@ data class HabitMatcher(
if (isReminderRequired && !habit.hasReminder()) return false if (isReminderRequired && !habit.hasReminder()) return false
if (!isCompletedAllowed && habit.isCompletedToday()) return false if (!isCompletedAllowed && habit.isCompletedToday()) return false
if (!isEnteredAllowed && habit.isEnteredToday()) return false if (!isEnteredAllowed && habit.isEnteredToday()) return false
if (habit.collapsed) return false
return true
}
fun matches(habitGroup: HabitGroup): Boolean {
if (!isArchivedAllowed && habitGroup.isArchived) return false
if (isReminderRequired && !habitGroup.hasReminder()) return false
if (!isCompletedAllowed && habitGroup.isCompletedToday()) return false
if (!isEnteredAllowed && habitGroup.isEnteredToday()) return false
return true return true
} }

@ -20,6 +20,7 @@ package org.isoron.uhabits.core.models
import org.isoron.uhabits.core.database.Repository import org.isoron.uhabits.core.database.Repository
import org.isoron.uhabits.core.models.sqlite.records.EntryRecord import org.isoron.uhabits.core.models.sqlite.records.EntryRecord
import org.isoron.uhabits.core.models.sqlite.records.HabitGroupRecord
import org.isoron.uhabits.core.models.sqlite.records.HabitRecord import org.isoron.uhabits.core.models.sqlite.records.HabitRecord
/** /**
@ -38,11 +39,23 @@ interface ModelFactory {
computedEntries = buildComputedEntries() computedEntries = buildComputedEntries()
) )
} }
fun buildHabitGroup(): HabitGroup {
val habits = buildHabitList()
val scores = buildScoreList()
val streaks = buildStreakList()
return HabitGroup(
habitList = habits,
scores = scores,
streaks = streaks
)
}
fun buildComputedEntries(): EntryList fun buildComputedEntries(): EntryList
fun buildOriginalEntries(): EntryList fun buildOriginalEntries(): EntryList
fun buildHabitList(): HabitList fun buildHabitList(): HabitList
fun buildHabitGroupList(): HabitGroupList
fun buildScoreList(): ScoreList fun buildScoreList(): ScoreList
fun buildStreakList(): StreakList fun buildStreakList(): StreakList
fun buildHabitListRepository(): Repository<HabitRecord> fun buildHabitListRepository(): Repository<HabitRecord>
fun buildRepetitionListRepository(): Repository<EntryRecord> fun buildRepetitionListRepository(): Repository<EntryRecord>
fun buildHabitGroupListRepository(): Repository<HabitGroupRecord>
} }

@ -19,8 +19,6 @@
package org.isoron.uhabits.core.models package org.isoron.uhabits.core.models
import org.isoron.uhabits.core.models.Score.Companion.compute import org.isoron.uhabits.core.models.Score.Companion.compute
import java.util.ArrayList
import java.util.HashMap
import javax.annotation.concurrent.ThreadSafe import javax.annotation.concurrent.ThreadSafe
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
@ -138,4 +136,21 @@ class ScoreList {
map[timestamp] = Score(timestamp, previousValue) map[timestamp] = Score(timestamp, previousValue)
} }
} }
@Synchronized
fun combineFrom(
habitList: HabitList,
from: Timestamp,
to: Timestamp
) {
var current = to
while (current >= from) {
val habitScores = habitList
.filter { !it.isArchived }
.map { it.scores[current].value }
val averageScore = if (habitScores.isNotEmpty()) habitScores.average() else 0.0
map[current] = Score(current, averageScore)
current = current.minus(1)
}
}
} }

@ -38,4 +38,8 @@ data class Streak(
val length: Int val length: Int
get() = start.daysUntil(end) + 1 get() = start.daysUntil(end) + 1
fun isInStreak(timestamp: Timestamp): Boolean {
return timestamp in start..end
}
} }

@ -62,4 +62,38 @@ class StreakList {
} }
list.add(Streak(begin, end)) list.add(Streak(begin, end))
} }
@Synchronized
fun isInStreaks(timestamp: Timestamp): Boolean {
return list.any { it.isInStreak(timestamp) }
}
@Synchronized
fun combineFrom(
habitList: HabitList,
from: Timestamp,
to: Timestamp
) {
list.clear()
if (habitList.isEmpty) return
var current = from
var streakRunning = false
var streakStart = from
val notArchivedHabits = habitList.filter { !it.isArchived }
while (current <= to) {
if (notArchivedHabits.all { it.streaks.isInStreaks(current) }) {
if (!streakRunning) {
streakStart = current
streakRunning = true
}
} else {
if (streakRunning) {
val streakEnd = current.minus(1)
list.add(Streak(streakStart, streakEnd))
streakRunning = false
}
}
current = current.plus(1)
}
}
} }

@ -0,0 +1,225 @@
package org.isoron.uhabits.core.models.memory
import org.isoron.uhabits.core.models.HabitGroup
import org.isoron.uhabits.core.models.HabitGroupList
import org.isoron.uhabits.core.models.HabitList.Order
import org.isoron.uhabits.core.models.HabitMatcher
import org.isoron.uhabits.core.utils.DateUtils.Companion.getTodayWithOffset
import java.util.LinkedList
import java.util.Objects
/**
* In-memory implementation of [HabitGroupList].
*/
class MemoryHabitGroupList : HabitGroupList {
private val list = LinkedList<HabitGroup>()
@get:Synchronized
override var primaryOrder = Order.BY_POSITION
set(value) {
field = value
comparator = getComposedComparatorByOrder(primaryOrder, secondaryOrder)
resort()
}
@get:Synchronized
override var secondaryOrder = Order.BY_NAME_ASC
set(value) {
field = value
comparator = getComposedComparatorByOrder(primaryOrder, secondaryOrder)
resort()
}
private var comparator: Comparator<HabitGroup>? =
getComposedComparatorByOrder(primaryOrder, secondaryOrder)
private var parent: MemoryHabitGroupList? = null
constructor() : super()
constructor(
matcher: HabitMatcher,
comparator: Comparator<HabitGroup>?,
parent: MemoryHabitGroupList
) : super(matcher) {
this.parent = parent
this.comparator = comparator
primaryOrder = parent.primaryOrder
secondaryOrder = parent.secondaryOrder
parent.observable.addListener { loadFromParent() }
loadFromParent()
}
@Synchronized
@Throws(IllegalArgumentException::class)
override fun add(habitGroup: HabitGroup) {
throwIfHasParent()
require(!list.contains(habitGroup)) { "habit already added" }
val id = habitGroup.id
if (id != null && getById(id) != null) throw RuntimeException("duplicate id")
if (id == null) habitGroup.id = list.size.toLong()
list.addLast(habitGroup)
resort()
}
@Synchronized
override fun getById(id: Long): HabitGroup? {
for (h in list) {
checkNotNull(h.id)
if (h.id == id) return h
}
return null
}
@Synchronized
override fun getByUUID(uuid: String?): HabitGroup? {
for (h in list) if (Objects.requireNonNull(h.uuid) == uuid) return h
return null
}
@Synchronized
override fun getByPosition(position: Int): HabitGroup {
return list[position]
}
@Synchronized
override fun getFiltered(matcher: HabitMatcher?): HabitGroupList {
return MemoryHabitGroupList(matcher!!, comparator, this)
}
private fun getComposedComparatorByOrder(
firstOrder: Order,
secondOrder: Order?
): Comparator<HabitGroup> {
return Comparator { h1: HabitGroup, h2: HabitGroup ->
val firstResult = getComparatorByOrder(firstOrder).compare(h1, h2)
if (firstResult != 0 || secondOrder == null) {
return@Comparator firstResult
}
getComparatorByOrder(secondOrder).compare(h1, h2)
}
}
private fun getComparatorByOrder(order: Order): Comparator<HabitGroup> {
val nameComparatorAsc = Comparator<HabitGroup> { habit1, habit2 ->
habit1.name.compareTo(habit2.name)
}
val nameComparatorDesc =
Comparator { h1: HabitGroup, h2: HabitGroup -> nameComparatorAsc.compare(h2, h1) }
val colorComparatorAsc = Comparator<HabitGroup> { (color1), (color2) ->
color1.compareTo(color2)
}
val colorComparatorDesc =
Comparator { h1: HabitGroup, h2: HabitGroup -> colorComparatorAsc.compare(h2, h1) }
val scoreComparatorDesc =
Comparator<HabitGroup> { habit1, habit2 ->
val today = getTodayWithOffset()
habit1.scores[today].value.compareTo(habit2.scores[today].value)
}
val scoreComparatorAsc =
Comparator { h1: HabitGroup, h2: HabitGroup -> scoreComparatorDesc.compare(h2, h1) }
val positionComparator =
Comparator<HabitGroup> { habit1, habit2 -> habit1.position.compareTo(habit2.position) }
val statusComparatorDesc = Comparator { h1: HabitGroup, h2: HabitGroup ->
if (h1.isCompletedToday() != h2.isCompletedToday()) {
return@Comparator if (h1.isCompletedToday()) -1 else 1
}
val today = getTodayWithOffset()
val v1 = h1.scores[today].value
val v2 = h2.scores[today].value
v2.compareTo(v1)
}
val statusComparatorAsc =
Comparator { h1: HabitGroup, h2: HabitGroup -> statusComparatorDesc.compare(h2, h1) }
return when {
order === Order.BY_POSITION -> positionComparator
order === Order.BY_NAME_ASC -> nameComparatorAsc
order === Order.BY_NAME_DESC -> nameComparatorDesc
order === Order.BY_COLOR_ASC -> colorComparatorAsc
order === Order.BY_COLOR_DESC -> colorComparatorDesc
order === Order.BY_SCORE_DESC -> scoreComparatorDesc
order === Order.BY_SCORE_ASC -> scoreComparatorAsc
order === Order.BY_STATUS_DESC -> statusComparatorDesc
order === Order.BY_STATUS_ASC -> statusComparatorAsc
else -> throw IllegalStateException()
}
}
@Synchronized
override fun indexOf(h: HabitGroup): Int {
return list.indexOf(h)
}
@Synchronized
override fun iterator(): Iterator<HabitGroup> {
return ArrayList(list).iterator()
}
@Synchronized
override fun remove(h: HabitGroup) {
throwIfHasParent()
list.remove(h)
observable.notifyListeners()
}
@Synchronized
override fun reorder(from: HabitGroup, to: HabitGroup) {
throwIfHasParent()
check(!(primaryOrder !== Order.BY_POSITION)) { "cannot reorder automatically sorted list" }
require(indexOf(from) >= 0) { "list does not contain (from) habit" }
val toPos = indexOf(to)
require(toPos >= 0) { "list does not contain (to) habit" }
list.remove(from)
list.add(toPos, from)
var position = 0
for (h in list) h.position = position++
observable.notifyListeners()
}
@Synchronized
override fun size(): Int {
return list.size
}
@Synchronized
override fun update(habitGroups: List<HabitGroup>) {
resort()
}
override fun attachHabitsToGroups() {
for (hgr in list) {
for (h in hgr.habitList) {
h.group = hgr
}
}
}
private fun throwIfHasParent() {
check(parent == null) {
"Filtered lists cannot be modified directly. " +
"You should modify the parent list instead."
}
}
@Synchronized
private fun loadFromParent() {
checkNotNull(parent)
list.clear()
for (hgr in parent!!) {
if (filter.matches(hgr)) {
val filteredHgr = HabitGroup(hgr, filter)
list.add(filteredHgr)
}
}
primaryOrder = parent!!.primaryOrder
secondaryOrder = parent!!.secondaryOrder
}
@Synchronized
override fun resort() {
for (hgr in list) {
hgr.habitList.primaryOrder = primaryOrder
hgr.habitList.secondaryOrder = secondaryOrder
}
if (comparator != null) list.sortWith(comparator!!)
observable.notifyListeners()
}
}

@ -22,8 +22,6 @@ import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.HabitList import org.isoron.uhabits.core.models.HabitList
import org.isoron.uhabits.core.models.HabitMatcher import org.isoron.uhabits.core.models.HabitMatcher
import org.isoron.uhabits.core.utils.DateUtils.Companion.getTodayWithOffset import org.isoron.uhabits.core.utils.DateUtils.Companion.getTodayWithOffset
import java.util.ArrayList
import java.util.Comparator
import java.util.LinkedList import java.util.LinkedList
import java.util.Objects import java.util.Objects
@ -53,6 +51,13 @@ class MemoryHabitList : HabitList {
getComposedComparatorByOrder(primaryOrder, secondaryOrder) getComposedComparatorByOrder(primaryOrder, secondaryOrder)
private var parent: MemoryHabitList? = null private var parent: MemoryHabitList? = null
override var collapsed: Boolean = false
set(value) {
field = value
val habits = parent?.list ?: list
habits.forEach { it.collapsed = value }
}
constructor() : super() constructor() : super()
constructor( constructor(
matcher: HabitMatcher, matcher: HabitMatcher,
@ -61,6 +66,7 @@ class MemoryHabitList : HabitList {
) : super(matcher) { ) : super(matcher) {
this.parent = parent this.parent = parent
this.comparator = comparator this.comparator = comparator
this.groupId = parent.groupId
primaryOrder = parent.primaryOrder primaryOrder = parent.primaryOrder
secondaryOrder = parent.secondaryOrder secondaryOrder = parent.secondaryOrder
parent.observable.addListener { loadFromParent() } parent.observable.addListener { loadFromParent() }
@ -79,6 +85,17 @@ class MemoryHabitList : HabitList {
resort() resort()
} }
@Synchronized
@Throws(IllegalArgumentException::class)
override fun add(position: Int, habit: Habit) {
throwIfHasParent()
require(!list.contains(habit)) { "habit already added" }
val id = habit.id
if (id != null && getById(id) != null) throw RuntimeException("duplicate id")
if (id == null) habit.id = list.size.toLong()
list.add(position, habit)
}
@Synchronized @Synchronized
override fun getById(id: Long): Habit? { override fun getById(id: Long): Habit? {
for (h in list) { for (h in list) {
@ -182,6 +199,13 @@ class MemoryHabitList : HabitList {
observable.notifyListeners() observable.notifyListeners()
} }
@Synchronized
override fun removeAt(position: Int) {
throwIfHasParent()
list.removeAt(position)
observable.notifyListeners()
}
@Synchronized @Synchronized
override fun reorder(from: Habit, to: Habit) { override fun reorder(from: Habit, to: Habit) {
throwIfHasParent() throwIfHasParent()
@ -218,6 +242,8 @@ class MemoryHabitList : HabitList {
checkNotNull(parent) checkNotNull(parent)
list.clear() list.clear()
for (h in parent!!) if (filter.matches(h)) list.add(h) for (h in parent!!) if (filter.matches(h)) list.add(h)
primaryOrder = parent!!.primaryOrder
secondaryOrder = parent!!.secondaryOrder
resort() resort()
} }

@ -27,8 +27,10 @@ class MemoryModelFactory : ModelFactory {
override fun buildComputedEntries() = EntryList() override fun buildComputedEntries() = EntryList()
override fun buildOriginalEntries() = EntryList() override fun buildOriginalEntries() = EntryList()
override fun buildHabitList() = MemoryHabitList() override fun buildHabitList() = MemoryHabitList()
override fun buildHabitGroupList() = MemoryHabitGroupList()
override fun buildScoreList() = ScoreList() override fun buildScoreList() = ScoreList()
override fun buildStreakList() = StreakList() override fun buildStreakList() = StreakList()
override fun buildHabitListRepository() = throw NotImplementedError() override fun buildHabitListRepository() = throw NotImplementedError()
override fun buildRepetitionListRepository() = throw NotImplementedError() override fun buildRepetitionListRepository() = throw NotImplementedError()
override fun buildHabitGroupListRepository() = throw NotImplementedError()
} }

@ -25,6 +25,7 @@ import org.isoron.uhabits.core.models.ModelFactory
import org.isoron.uhabits.core.models.ScoreList import org.isoron.uhabits.core.models.ScoreList
import org.isoron.uhabits.core.models.StreakList import org.isoron.uhabits.core.models.StreakList
import org.isoron.uhabits.core.models.sqlite.records.EntryRecord import org.isoron.uhabits.core.models.sqlite.records.EntryRecord
import org.isoron.uhabits.core.models.sqlite.records.HabitGroupRecord
import org.isoron.uhabits.core.models.sqlite.records.HabitRecord import org.isoron.uhabits.core.models.sqlite.records.HabitRecord
import javax.inject.Inject import javax.inject.Inject
@ -38,6 +39,8 @@ class SQLModelFactory
override fun buildOriginalEntries() = SQLiteEntryList(database) override fun buildOriginalEntries() = SQLiteEntryList(database)
override fun buildComputedEntries() = EntryList() override fun buildComputedEntries() = EntryList()
override fun buildHabitList() = SQLiteHabitList(this) override fun buildHabitList() = SQLiteHabitList(this)
override fun buildHabitGroupList() = SQLiteHabitGroupList(this)
override fun buildScoreList() = ScoreList() override fun buildScoreList() = ScoreList()
override fun buildStreakList() = StreakList() override fun buildStreakList() = StreakList()
@ -46,4 +49,7 @@ class SQLModelFactory
override fun buildRepetitionListRepository() = override fun buildRepetitionListRepository() =
Repository(EntryRecord::class.java, database) Repository(EntryRecord::class.java, database)
override fun buildHabitGroupListRepository() =
Repository(HabitGroupRecord::class.java, database)
} }

@ -81,12 +81,4 @@ class SQLiteEntryList(database: Database) : EntryList() {
override fun recomputeFrom(originalEntries: EntryList, frequency: Frequency, isNumerical: Boolean) { override fun recomputeFrom(originalEntries: EntryList, frequency: Frequency, isNumerical: Boolean) {
throw UnsupportedOperationException() throw UnsupportedOperationException()
} }
override fun clear() {
super.clear()
repository.execSQL(
"delete from repetitions where habit = ?",
habitId.toString()
)
}
} }

@ -0,0 +1,205 @@
package org.isoron.uhabits.core.models.sqlite
import org.isoron.uhabits.core.database.Repository
import org.isoron.uhabits.core.models.HabitGroup
import org.isoron.uhabits.core.models.HabitGroupList
import org.isoron.uhabits.core.models.HabitList.Order
import org.isoron.uhabits.core.models.HabitMatcher
import org.isoron.uhabits.core.models.ModelFactory
import org.isoron.uhabits.core.models.memory.MemoryHabitGroupList
import org.isoron.uhabits.core.models.sqlite.records.HabitGroupRecord
import javax.inject.Inject
/**
* Implementation of a [HabitGroupList] that is backed by SQLite.
*/
class SQLiteHabitGroupList @Inject constructor(private val modelFactory: ModelFactory) : HabitGroupList() {
private val repository: Repository<HabitGroupRecord> = modelFactory.buildHabitGroupListRepository()
private val list: MemoryHabitGroupList = MemoryHabitGroupList()
private var loaded = false
private fun loadRecords() {
if (loaded) return
loaded = true
list.removeAll()
val records = repository.findAll("order by position")
var shouldRebuildOrder = false
for ((expectedPosition, rec) in records.withIndex()) {
if (rec.position != expectedPosition) shouldRebuildOrder = true
val h = modelFactory.buildHabitGroup()
rec.copyTo(h)
list.add(h)
}
if (shouldRebuildOrder) rebuildOrder()
}
@Synchronized
override fun add(habitGroup: HabitGroup) {
loadRecords()
habitGroup.position = size()
habitGroup.id = repository.getNextAvailableId("habitandgroup")
val record = HabitGroupRecord()
record.copyFrom(habitGroup)
repository.save(record)
habitGroup.habitList.groupId = record.id
list.add(habitGroup)
observable.notifyListeners()
}
@Synchronized
override fun getById(id: Long): HabitGroup? {
loadRecords()
return list.getById(id)
}
@Synchronized
override fun getByUUID(uuid: String?): HabitGroup? {
loadRecords()
return list.getByUUID(uuid)
}
@Synchronized
override fun getByPosition(position: Int): HabitGroup {
loadRecords()
return list.getByPosition(position)
}
@Synchronized
override fun getFiltered(matcher: HabitMatcher?): HabitGroupList {
loadRecords()
return list.getFiltered(matcher)
}
@set:Synchronized
override var primaryOrder: Order
get() = list.primaryOrder
set(order) {
list.primaryOrder = order
observable.notifyListeners()
}
@set:Synchronized
override var secondaryOrder: Order
get() = list.secondaryOrder
set(order) {
list.secondaryOrder = order
observable.notifyListeners()
}
@Synchronized
override fun indexOf(h: HabitGroup): Int {
loadRecords()
return list.indexOf(h)
}
@Synchronized
override fun iterator(): Iterator<HabitGroup> {
loadRecords()
return list.iterator()
}
@Synchronized
private fun rebuildOrder() {
val records = repository.findAll("order by position")
repository.executeAsTransaction {
for ((pos, r) in records.withIndex()) {
if (r.position != pos) {
r.position = pos
repository.save(r)
}
}
}
}
@Synchronized
override fun remove(h: HabitGroup) {
loadRecords()
list.remove(h)
val record = repository.find(
h.id!!
) ?: throw RuntimeException("habit not in database")
repository.executeAsTransaction {
repository.remove(record)
}
rebuildOrder()
observable.notifyListeners()
}
@Synchronized
override fun removeAll() {
list.removeAll()
repository.execSQL("delete from habits")
repository.execSQL("delete from repetitions")
observable.notifyListeners()
}
@Synchronized
override fun reorder(from: HabitGroup, to: HabitGroup) {
loadRecords()
list.reorder(from, to)
val fromRecord = repository.find(
from.id!!
)
val toRecord = repository.find(
to.id!!
)
if (fromRecord == null) throw RuntimeException("habit not in database")
if (toRecord == null) throw RuntimeException("habit not in database")
if (toRecord.position!! < fromRecord.position!!) {
repository.execSQL(
"update habitgroups set position = position + 1 " +
"where position >= ? and position < ?",
toRecord.position!!,
fromRecord.position!!
)
} else {
repository.execSQL(
"update habitgroups set position = position - 1 " +
"where position > ? and position <= ?",
fromRecord.position!!,
toRecord.position!!
)
}
fromRecord.position = toRecord.position
repository.save(fromRecord)
observable.notifyListeners()
}
@Synchronized
override fun repair() {
loadRecords()
rebuildOrder()
observable.notifyListeners()
}
@Synchronized
override fun size(): Int {
loadRecords()
return list.size()
}
@Synchronized
override fun update(habitGroups: List<HabitGroup>) {
loadRecords()
list.update(habitGroups)
for (h in habitGroups) {
val record = repository.find(h.id!!) ?: continue
record.copyFrom(h)
repository.save(record)
}
observable.notifyListeners()
}
override fun attachHabitsToGroups() {
list.attachHabitsToGroups()
}
override fun resort() {
list.resort()
observable.notifyListeners()
}
@Synchronized
fun reload() {
loaded = false
}
}

@ -37,15 +37,23 @@ class SQLiteHabitList @Inject constructor(private val modelFactory: ModelFactory
private fun loadRecords() { private fun loadRecords() {
if (loaded) return if (loaded) return
loaded = true loaded = true
list.groupId = this.groupId
list.removeAll() list.removeAll()
val records = repository.findAll("order by position") val records = repository.findAll("order by group_id, position")
var shouldRebuildOrder = false var shouldRebuildOrder = false
for ((expectedPosition, rec) in records.withIndex()) { var currentGroup: Long? = null
var expectedPosition = 0
for (rec in records) {
if (currentGroup != rec.groupId) {
currentGroup = rec.groupId
expectedPosition = 0
}
if (rec.position != expectedPosition) shouldRebuildOrder = true if (rec.position != expectedPosition) shouldRebuildOrder = true
val h = modelFactory.buildHabit() val h = modelFactory.buildHabit()
rec.copyTo(h) rec.copyTo(h)
(h.originalEntries as SQLiteEntryList).habitId = h.id (h.originalEntries as SQLiteEntryList).habitId = h.id
list.add(h) if (h.groupId == list.groupId) list.add(h)
expectedPosition++
} }
if (shouldRebuildOrder) rebuildOrder() if (shouldRebuildOrder) rebuildOrder()
} }
@ -54,15 +62,48 @@ class SQLiteHabitList @Inject constructor(private val modelFactory: ModelFactory
override fun add(habit: Habit) { override fun add(habit: Habit) {
loadRecords() loadRecords()
habit.position = size() habit.position = size()
habit.id = repository.getNextAvailableId("habitandgroup")
val record = HabitRecord() val record = HabitRecord()
record.copyFrom(habit) record.copyFrom(habit)
repository.save(record) repository.save(record)
habit.id = record.id
(habit.originalEntries as SQLiteEntryList).habitId = record.id (habit.originalEntries as SQLiteEntryList).habitId = record.id
list.add(habit) list.add(habit)
observable.notifyListeners() observable.notifyListeners()
} }
@Synchronized
private fun rebuildOrder() {
val records = repository.findAll("order by group_id, position")
repository.executeAsTransaction {
var currentGroup: Long? = null
var expectedPosition = 0
for (r in records) {
if (currentGroup != r.groupId) {
currentGroup = r.groupId
expectedPosition = 0
}
if (r.position != expectedPosition) {
r.position = expectedPosition
repository.save(r)
}
expectedPosition++
}
}
}
@Synchronized
override fun add(position: Int, habit: Habit) {
loadRecords()
habit.position = size()
val record = HabitRecord()
record.copyFrom(habit)
repository.save(record)
habit.id = record.id
(habit.originalEntries as SQLiteEntryList).habitId = record.id
list.add(position, habit)
observable.notifyListeners()
}
@Synchronized @Synchronized
override fun getById(id: Long): Habit? { override fun getById(id: Long): Habit? {
loadRecords() loadRecords()
@ -103,6 +144,12 @@ class SQLiteHabitList @Inject constructor(private val modelFactory: ModelFactory
observable.notifyListeners() observable.notifyListeners()
} }
override var collapsed: Boolean = list.collapsed
set(value) {
field = value
list.collapsed = value
}
@Synchronized @Synchronized
override fun indexOf(h: Habit): Int { override fun indexOf(h: Habit): Int {
loadRecords() loadRecords()
@ -115,19 +162,6 @@ class SQLiteHabitList @Inject constructor(private val modelFactory: ModelFactory
return list.iterator() return list.iterator()
} }
@Synchronized
private fun rebuildOrder() {
val records = repository.findAll("order by position")
repository.executeAsTransaction {
for ((pos, r) in records.withIndex()) {
if (r.position != pos) {
r.position = pos
repository.save(r)
}
}
}
}
@Synchronized @Synchronized
override fun remove(h: Habit) { override fun remove(h: Habit) {
loadRecords() loadRecords()
@ -143,6 +177,12 @@ class SQLiteHabitList @Inject constructor(private val modelFactory: ModelFactory
observable.notifyListeners() observable.notifyListeners()
} }
@Synchronized
override fun removeAt(position: Int) {
loadRecords()
list.removeAt(position)
}
@Synchronized @Synchronized
override fun removeAll() { override fun removeAll() {
list.removeAll() list.removeAll()

@ -0,0 +1,91 @@
package org.isoron.uhabits.core.models.sqlite.records
import org.isoron.uhabits.core.database.Column
import org.isoron.uhabits.core.database.Table
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 java.util.Objects.requireNonNull
/**
* The SQLite database record corresponding to a [HabitGroup].
*/
@Table(name = "habitgroups")
class HabitGroupRecord {
@field:Column
var description: String? = null
@field:Column
var question: String? = null
@field:Column
var name: String? = null
@field:Column
var color: Int? = null
@field:Column
var position: Int? = null
@field:Column(name = "reminder_hour")
var reminderHour: Int? = null
@field:Column(name = "reminder_min")
var reminderMin: Int? = null
@field:Column(name = "reminder_days")
var reminderDays: Int? = null
@field:Column
var highlight: Int? = null
@field:Column
var archived: Int? = null
@field:Column
var id: Long? = null
@field:Column
var uuid: String? = null
fun copyFrom(model: HabitGroup) {
id = model.id
name = model.name
description = model.description
highlight = 0
color = model.color.paletteIndex
archived = if (model.isArchived) 1 else 0
position = model.position
question = model.question
uuid = model.uuid
reminderDays = 0
reminderMin = null
reminderHour = null
if (model.hasReminder()) {
val reminder = model.reminder
reminderHour = requireNonNull(reminder)!!.hour
reminderMin = reminder!!.minute
reminderDays = reminder.days.toInteger()
}
}
fun copyTo(habitGroup: HabitGroup) {
habitGroup.id = id
habitGroup.name = name!!
habitGroup.description = description!!
habitGroup.question = question!!
habitGroup.color = PaletteColor(color!!)
habitGroup.isArchived = archived != 0
habitGroup.position = position!!
habitGroup.uuid = uuid
habitGroup.habitList.groupId = id
if (reminderHour != null && reminderMin != null) {
habitGroup.reminder = Reminder(
reminderHour!!,
reminderMin!!,
WeekdayList(reminderDays!!)
)
}
}
}

@ -88,6 +88,12 @@ class HabitRecord {
@field:Column @field:Column
var uuid: String? = null var uuid: String? = null
@field:Column(name = "group_id")
var groupId: Long? = null
@field:Column(name = "group_uuid")
var groupUUID: String? = null
fun copyFrom(model: Habit) { fun copyFrom(model: Habit) {
id = model.id id = model.id
name = model.name name = model.name
@ -102,6 +108,8 @@ class HabitRecord {
position = model.position position = model.position
question = model.question question = model.question
uuid = model.uuid uuid = model.uuid
groupId = model.groupId
groupUUID = model.groupUUID
val (numerator, denominator) = model.frequency val (numerator, denominator) = model.frequency
freqNum = numerator freqNum = numerator
freqDen = denominator freqDen = denominator
@ -130,6 +138,8 @@ class HabitRecord {
habit.unit = unit!! habit.unit = unit!!
habit.position = position!! habit.position = position!!
habit.uuid = uuid habit.uuid = uuid
habit.groupId = groupId
habit.groupUUID = groupUUID
if (reminderHour != null && reminderMin != null) { if (reminderHour != null && reminderMin != null) {
habit.reminder = Reminder( habit.reminder = Reminder(
reminderHour!!, reminderHour!!,

@ -24,6 +24,8 @@ import org.isoron.uhabits.core.commands.Command
import org.isoron.uhabits.core.commands.CommandRunner import org.isoron.uhabits.core.commands.CommandRunner
import org.isoron.uhabits.core.commands.CreateRepetitionCommand import org.isoron.uhabits.core.commands.CreateRepetitionCommand
import org.isoron.uhabits.core.models.Habit 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.HabitList
import org.isoron.uhabits.core.models.HabitMatcher import org.isoron.uhabits.core.models.HabitMatcher
import org.isoron.uhabits.core.preferences.WidgetPreferences import org.isoron.uhabits.core.preferences.WidgetPreferences
@ -39,6 +41,7 @@ import javax.inject.Inject
class ReminderScheduler @Inject constructor( class ReminderScheduler @Inject constructor(
private val commandRunner: CommandRunner, private val commandRunner: CommandRunner,
private val habitList: HabitList, private val habitList: HabitList,
private val habitGroupList: HabitGroupList,
private val sys: SystemScheduler, private val sys: SystemScheduler,
private val widgetPreferences: WidgetPreferences private val widgetPreferences: WidgetPreferences
) : CommandRunner.Listener { ) : CommandRunner.Listener {
@ -83,6 +86,40 @@ class ReminderScheduler @Inject constructor(
scheduleAtTime(habit, reminderTime) scheduleAtTime(habit, reminderTime)
} }
@Synchronized
fun schedule(habitGroup: HabitGroup) {
if (habitGroup.id == null) {
sys.log("ReminderScheduler", "Habit group has null id. Returning.")
return
}
if (!habitGroup.hasReminder()) {
sys.log("ReminderScheduler", "habit group=" + habitGroup.id + " has no reminder. Skipping.")
return
}
var reminderTime = Objects.requireNonNull(habitGroup.reminder)!!.timeInMillis
val snoozeReminderTime = widgetPreferences.getSnoozeTime(habitGroup.id!!)
if (snoozeReminderTime != 0L) {
val now = applyTimezone(getLocalTime())
sys.log(
"ReminderScheduler",
String.format(
Locale.US,
"Habit group %d has been snoozed until %d",
habitGroup.id,
snoozeReminderTime
)
)
if (snoozeReminderTime > now) {
sys.log("ReminderScheduler", "Snooze time is in the future. Accepting.")
reminderTime = snoozeReminderTime
} else {
sys.log("ReminderScheduler", "Snooze time is in the past. Discarding.")
widgetPreferences.removeSnoozeTime(habitGroup.id!!)
}
}
scheduleAtTime(habitGroup, reminderTime)
}
@Synchronized @Synchronized
fun scheduleAtTime(habit: Habit, reminderTime: Long) { fun scheduleAtTime(habit: Habit, reminderTime: Long) {
sys.log("ReminderScheduler", "Scheduling alarm for habit=" + habit.id) sys.log("ReminderScheduler", "Scheduling alarm for habit=" + habit.id)
@ -108,16 +145,48 @@ class ReminderScheduler @Inject constructor(
sys.scheduleShowReminder(reminderTime, habit, timestamp) sys.scheduleShowReminder(reminderTime, habit, timestamp)
} }
@Synchronized
fun scheduleAtTime(habitGroup: HabitGroup, reminderTime: Long) {
sys.log("ReminderScheduler", "Scheduling alarm for habit group=" + habitGroup.id)
if (!habitGroup.hasReminder()) {
sys.log("ReminderScheduler", "habit group=" + habitGroup.id + " has no reminder. Skipping.")
return
}
if (habitGroup.isArchived) {
sys.log("ReminderScheduler", "habit group=" + habitGroup.id + " is archived. Skipping.")
return
}
val timestamp = getStartOfDayWithOffset(removeTimezone(reminderTime))
sys.log(
"ReminderScheduler",
String.format(
Locale.US,
"reminderTime=%d removeTimezone=%d timestamp=%d",
reminderTime,
removeTimezone(reminderTime),
timestamp
)
)
sys.scheduleShowReminder(reminderTime, habitGroup, timestamp)
}
@Synchronized @Synchronized
fun scheduleAll() { fun scheduleAll() {
sys.log("ReminderScheduler", "Scheduling all alarms") sys.log("ReminderScheduler", "Scheduling all alarms")
val reminderHabits = habitList.getFiltered(HabitMatcher.WITH_ALARM) val reminderHabits = habitList.getFiltered(HabitMatcher.WITH_ALARM)
val reminderSubHabits = habitGroupList.map { it.habitList.getFiltered(HabitMatcher.WITH_ALARM) }.flatten()
val reminderHabitGroups = habitGroupList.getFiltered(HabitMatcher.WITH_ALARM)
for (habit in reminderHabits) schedule(habit) for (habit in reminderHabits) schedule(habit)
for (habit in reminderSubHabits) schedule(habit)
for (hgr in reminderHabitGroups) schedule(hgr)
} }
@Synchronized @Synchronized
fun hasHabitsWithReminders(): Boolean { fun hasHabitsWithReminders(): Boolean {
return !habitList.getFiltered(HabitMatcher.WITH_ALARM).isEmpty if (!habitList.getFiltered(HabitMatcher.WITH_ALARM).isEmpty) return true
if (habitGroupList.map { it.habitList.getFiltered(HabitMatcher.WITH_ALARM) }.flatten().isNotEmpty()) return true
if (!habitGroupList.getFiltered(HabitMatcher.WITH_ALARM).isEmpty) return true
return false
} }
@Synchronized @Synchronized
@ -138,6 +207,14 @@ class ReminderScheduler @Inject constructor(
schedule(habit) schedule(habit)
} }
@Synchronized
fun snoozeReminder(habitGroup: HabitGroup, minutes: Long) {
val now = applyTimezone(getLocalTime())
val snoozedUntil = now + minutes * 60 * 1000
widgetPreferences.setSnoozeTime(habitGroup.id!!, snoozedUntil)
schedule(habitGroup)
}
interface SystemScheduler { interface SystemScheduler {
fun scheduleShowReminder( fun scheduleShowReminder(
reminderTime: Long, reminderTime: Long,
@ -145,6 +222,12 @@ class ReminderScheduler @Inject constructor(
timestamp: Long timestamp: Long
): SchedulerResult ): SchedulerResult
fun scheduleShowReminder(
reminderTime: Long,
habitGroup: HabitGroup,
timestamp: Long
): SchedulerResult
fun scheduleWidgetUpdate(updateTime: Long): SchedulerResult? fun scheduleWidgetUpdate(updateTime: Long): SchedulerResult?
fun log(componentName: String, msg: String) fun log(componentName: String, msg: String)
} }

@ -0,0 +1,150 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.test
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.ModelFactory
import org.isoron.uhabits.core.models.NumericalHabitType
import org.isoron.uhabits.core.models.PaletteColor
import org.isoron.uhabits.core.models.sqlite.SQLiteHabitList
class HabitGroupFixtures(private val modelFactory: ModelFactory, private val habitList: HabitList, private val habitGroupList: HabitGroupList) {
private val habitFixtures = HabitFixtures(modelFactory, habitList)
fun createEmptyHabitGroup(
name: String = "Exercise",
color: PaletteColor = PaletteColor(3),
position: Int = 0,
id: Long = 0L
): HabitGroup {
val hgr = modelFactory.buildHabitGroup()
hgr.name = name
hgr.id = id
hgr.question = "Did you exercise today?"
hgr.color = color
hgr.position = position
saveIfSQLite(hgr)
return hgr
}
fun createGroupWithEmptyHabits(
name: String = "Exercise",
color: PaletteColor = PaletteColor(3),
position: Int = 0,
numHabits: Int = 1,
id: Long = 0L
): HabitGroup {
val hgr = createEmptyHabitGroup(name, color, position, id)
for (i in 1..numHabits) {
val h = habitFixtures.createEmptyHabit()
h.id = id + i
addGroupId(h, hgr)
hgr.habitList.add(h)
}
saveIfSQLite(hgr)
return hgr
}
fun createGroupWithEmptyNumericalHabits(
name: String = "Exercise",
color: PaletteColor = PaletteColor(3),
position: Int = 0,
targetType: NumericalHabitType = NumericalHabitType.AT_LEAST,
numHabits: Int = 1,
id: Long = 0L
): HabitGroup {
val hgr = createEmptyHabitGroup(name, color, position, id)
for (i in 1..numHabits) {
val h = habitFixtures.createEmptyNumericalHabit(targetType)
h.id = id + i
addGroupId(h, hgr)
hgr.habitList.add(h)
}
saveIfSQLite(hgr)
return hgr
}
fun createGroupWithNumericalHabits(
name: String = "Exercise",
color: PaletteColor = PaletteColor(3),
position: Int = 0,
numHabits: Int = 1,
id: Long = 0L
): HabitGroup {
val hgr = createEmptyHabitGroup(name, color, position, id)
for (i in 1..numHabits) {
val h = habitFixtures.createNumericalHabit()
h.id = id + i
addGroupId(h, hgr)
hgr.habitList.add(h)
}
saveIfSQLite(hgr)
return hgr
}
fun createGroupWithLongHabits(
name: String = "Exercise",
color: PaletteColor = PaletteColor(3),
position: Int = 0,
numHabits: Int = 1,
id: Long = 0L
): HabitGroup {
val hgr = createEmptyHabitGroup(name, color, position, id)
for (i in 1..numHabits) {
val h = habitFixtures.createLongHabit()
h.id = id + i
addGroupId(h, hgr)
hgr.habitList.add(h)
}
saveIfSQLite(hgr)
return hgr
}
fun createGroupWithShortHabits(
name: String = "Exercise",
color: PaletteColor = PaletteColor(3),
position: Int = 0,
numHabits: Int = 1,
id: Long = 0L
): HabitGroup {
val hgr = createEmptyHabitGroup(name, color, position, id)
for (i in 1..numHabits) {
val h = habitFixtures.createShortHabit()
h.id = id + i
addGroupId(h, hgr)
hgr.habitList.add(h)
}
saveIfSQLite(hgr)
return hgr
}
private fun saveIfSQLite(hgr: HabitGroup) {
if (hgr.habitList !is SQLiteHabitList) return
habitGroupList.add(hgr)
}
private fun addGroupId(h: Habit, hgr: HabitGroup) {
h.groupId = hgr.id
h.group = hgr
h.groupUUID = hgr.uuid
}
}

@ -22,13 +22,14 @@ import org.isoron.uhabits.core.AppScope
import org.isoron.uhabits.core.commands.Command import org.isoron.uhabits.core.commands.Command
import org.isoron.uhabits.core.commands.CommandRunner import org.isoron.uhabits.core.commands.CommandRunner
import org.isoron.uhabits.core.commands.CreateRepetitionCommand import org.isoron.uhabits.core.commands.CreateRepetitionCommand
import org.isoron.uhabits.core.commands.DeleteHabitGroupsCommand
import org.isoron.uhabits.core.commands.DeleteHabitsCommand import org.isoron.uhabits.core.commands.DeleteHabitsCommand
import org.isoron.uhabits.core.models.Habit 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.models.Timestamp
import org.isoron.uhabits.core.preferences.Preferences import org.isoron.uhabits.core.preferences.Preferences
import org.isoron.uhabits.core.tasks.Task import org.isoron.uhabits.core.tasks.Task
import org.isoron.uhabits.core.tasks.TaskRunner import org.isoron.uhabits.core.tasks.TaskRunner
import java.util.HashMap
import java.util.Locale import java.util.Locale
import java.util.Objects import java.util.Objects
import javax.inject.Inject import javax.inject.Inject
@ -40,11 +41,18 @@ class NotificationTray @Inject constructor(
private val preferences: Preferences, private val preferences: Preferences,
private val systemTray: SystemTray private val systemTray: SystemTray
) : CommandRunner.Listener, Preferences.Listener { ) : CommandRunner.Listener, Preferences.Listener {
private val active: HashMap<Habit, NotificationData> = HashMap() private val activeHabits: HashMap<Habit, NotificationData> = HashMap()
private val activeHabitGroups: HashMap<HabitGroup, NotificationData> = HashMap()
fun cancel(habit: Habit) { fun cancel(habit: Habit) {
val notificationId = getNotificationId(habit) val notificationId = getNotificationId(habit)
systemTray.removeNotification(notificationId) systemTray.removeNotification(notificationId)
active.remove(habit) activeHabits.remove(habit)
}
fun cancel(habitGroup: HabitGroup) {
val notificationId = getNotificationId(habitGroup)
systemTray.removeNotification(notificationId)
activeHabitGroups.remove(habitGroup)
} }
override fun onCommandFinished(command: Command) { override fun onCommandFinished(command: Command) {
@ -56,6 +64,13 @@ class NotificationTray @Inject constructor(
val (_, deleted) = command val (_, deleted) = command
for (habit in deleted) cancel(habit) for (habit in deleted) cancel(habit)
} }
if (command is DeleteHabitGroupsCommand) {
val (_, deletedGroups) = command
for (hgr in deletedGroups) {
for (h in hgr.habitList) cancel(h)
cancel(hgr)
}
}
} }
override fun onNotificationsChanged() { override fun onNotificationsChanged() {
@ -64,10 +79,16 @@ class NotificationTray @Inject constructor(
fun show(habit: Habit, timestamp: Timestamp, reminderTime: Long) { fun show(habit: Habit, timestamp: Timestamp, reminderTime: Long) {
val data = NotificationData(timestamp, reminderTime) val data = NotificationData(timestamp, reminderTime)
active[habit] = data activeHabits[habit] = data
taskRunner.execute(ShowNotificationTask(habit, data)) taskRunner.execute(ShowNotificationTask(habit, data))
} }
fun show(habitGroup: HabitGroup, timestamp: Timestamp, reminderTime: Long) {
val data = NotificationData(timestamp, reminderTime)
activeHabitGroups[habitGroup] = data
taskRunner.execute(ShowNotificationTask(habitGroup, data))
}
fun startListening() { fun startListening() {
commandRunner.addListener(this) commandRunner.addListener(this)
preferences.addListener(this) preferences.addListener(this)
@ -83,18 +104,32 @@ class NotificationTray @Inject constructor(
return (id % Int.MAX_VALUE).toInt() return (id % Int.MAX_VALUE).toInt()
} }
private fun getNotificationId(habitGroup: HabitGroup): Int {
val id = habitGroup.id ?: return 0
return (id % Int.MAX_VALUE).toInt()
}
private fun reshowAll() { private fun reshowAll() {
for ((habit, data) in active.entries) { for ((habit, data) in activeHabits.entries) {
taskRunner.execute(ShowNotificationTask(habit, data)) taskRunner.execute(ShowNotificationTask(habit, data))
} }
for ((habitGroup, data) in activeHabitGroups.entries) {
taskRunner.execute(ShowNotificationTask(habitGroup, data))
}
} }
fun reshow(habit: Habit) { fun reshow(habit: Habit) {
active[habit]?.let { activeHabits[habit]?.let {
taskRunner.execute(ShowNotificationTask(habit, it)) taskRunner.execute(ShowNotificationTask(habit, it))
} }
} }
fun reshow(habitGroup: HabitGroup) {
activeHabitGroups[habitGroup]?.let {
taskRunner.execute(ShowNotificationTask(habitGroup, it))
}
}
interface SystemTray { interface SystemTray {
fun removeNotification(notificationId: Int) fun removeNotification(notificationId: Int)
fun showNotification( fun showNotification(
@ -104,48 +139,79 @@ class NotificationTray @Inject constructor(
reminderTime: Long reminderTime: Long
) )
fun showNotification(
habitGroup: HabitGroup,
notificationId: Int,
timestamp: Timestamp,
reminderTime: Long
)
fun log(msg: String) fun log(msg: String)
} }
internal class NotificationData(val timestamp: Timestamp, val reminderTime: Long) internal class NotificationData(val timestamp: Timestamp, val reminderTime: Long)
private inner class ShowNotificationTask(private val habit: Habit, data: NotificationData) : private inner class ShowNotificationTask private constructor(
Task { private val habit: Habit? = null,
private val habitGroup: HabitGroup? = null,
data: NotificationData
) : Task {
// Secondary constructor for Habit
constructor(habit: Habit, data: NotificationData) : this(habit, null, data)
// Secondary constructor for HabitGroup
constructor(habitGroup: HabitGroup, data: NotificationData) : this(null, habitGroup, data)
var isCompleted = false var isCompleted = false
private val timestamp: Timestamp = data.timestamp private val timestamp: Timestamp = data.timestamp
private val reminderTime: Long = data.reminderTime private val reminderTime: Long = data.reminderTime
private val type = if (habit != null) "habit" else "habitgroup"
private val id = habit?.id ?: habitGroup?.id
private val hasReminder = habit?.hasReminder() ?: habitGroup!!.hasReminder()
private val isArchived = habit?.isArchived ?: habitGroup!!.isArchived
override fun doInBackground() { override fun doInBackground() {
isCompleted = habit.isCompletedToday() isCompleted = habit?.isCompletedToday() ?: habitGroup!!.isCompletedToday()
} }
override fun onPostExecute() { override fun onPostExecute() {
systemTray.log("Showing notification for habit=" + habit.id) systemTray.log(
String.format(
Locale.US,
"Showing notification for %s=%d",
type,
id
)
)
if (isCompleted) { if (isCompleted) {
systemTray.log( systemTray.log(
String.format( String.format(
Locale.US, Locale.US,
"Habit %d already checked. Skipping.", "%s %d already checked. Skipping.",
habit.id type,
id
) )
) )
return return
} }
if (!habit.hasReminder()) { if (!hasReminder) {
systemTray.log( systemTray.log(
String.format( String.format(
Locale.US, Locale.US,
"Habit %d does not have a reminder. Skipping.", "%s %d does not have a reminder. Skipping.",
habit.id type,
id
) )
) )
return return
} }
if (habit.isArchived) { if (isArchived) {
systemTray.log( systemTray.log(
String.format( String.format(
Locale.US, Locale.US,
"Habit %d is archived. Skipping.", "%s %d is archived. Skipping.",
habit.id type,
id
) )
) )
return return
@ -154,23 +220,33 @@ class NotificationTray @Inject constructor(
systemTray.log( systemTray.log(
String.format( String.format(
Locale.US, Locale.US,
"Habit %d not supposed to run today. Skipping.", "%s %d not supposed to run today. Skipping.",
habit.id type,
id
) )
) )
return return
} }
systemTray.showNotification( if (habit != null) {
habit, systemTray.showNotification(
getNotificationId(habit), habit,
timestamp, getNotificationId(habit),
reminderTime timestamp,
) reminderTime
)
} else {
systemTray.showNotification(
habitGroup!!,
getNotificationId(habitGroup),
timestamp,
reminderTime
)
}
} }
private fun shouldShowReminderToday(): Boolean { private fun shouldShowReminderToday(): Boolean {
if (!habit.hasReminder()) return false if (!hasReminder) return false
val reminder = habit.reminder val reminder = habit?.reminder ?: habitGroup!!.reminder
val reminderDays = Objects.requireNonNull(reminder)!!.days.toArray() val reminderDays = Objects.requireNonNull(reminder)!!.days.toArray()
val weekday = timestamp.weekday val weekday = timestamp.weekday
return reminderDays[weekday] return reminderDays[weekday]

@ -25,15 +25,15 @@ import org.isoron.uhabits.core.commands.CommandRunner
import org.isoron.uhabits.core.commands.CreateRepetitionCommand import org.isoron.uhabits.core.commands.CreateRepetitionCommand
import org.isoron.uhabits.core.io.Logging import org.isoron.uhabits.core.io.Logging
import org.isoron.uhabits.core.models.Habit 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.HabitList
import org.isoron.uhabits.core.models.HabitList.Order import org.isoron.uhabits.core.models.HabitList.Order
import org.isoron.uhabits.core.models.HabitMatcher import org.isoron.uhabits.core.models.HabitMatcher
import org.isoron.uhabits.core.tasks.Task import org.isoron.uhabits.core.tasks.Task
import org.isoron.uhabits.core.tasks.TaskRunner import org.isoron.uhabits.core.tasks.TaskRunner
import org.isoron.uhabits.core.utils.DateUtils.Companion.getTodayWithOffset import org.isoron.uhabits.core.utils.DateUtils.Companion.getTodayWithOffset
import java.util.ArrayList
import java.util.Arrays import java.util.Arrays
import java.util.HashMap
import java.util.LinkedList import java.util.LinkedList
import java.util.TreeSet import java.util.TreeSet
import javax.inject.Inject import javax.inject.Inject
@ -53,7 +53,8 @@ import javax.inject.Inject
*/ */
@AppScope @AppScope
class HabitCardListCache @Inject constructor( class HabitCardListCache @Inject constructor(
private val allHabits: HabitList, private val habits: HabitList,
private val habitGroups: HabitGroupList,
private val commandRunner: CommandRunner, private val commandRunner: CommandRunner,
taskRunner: TaskRunner, taskRunner: TaskRunner,
logging: Logging logging: Logging
@ -66,6 +67,7 @@ class HabitCardListCache @Inject constructor(
private var listener: Listener private var listener: Listener
private val data: CacheData private val data: CacheData
private var filteredHabits: HabitList private var filteredHabits: HabitList
private var filteredHabitGroups: HabitGroupList
private val taskRunner: TaskRunner private val taskRunner: TaskRunner
@Synchronized @Synchronized
@ -74,42 +76,86 @@ class HabitCardListCache @Inject constructor(
} }
@Synchronized @Synchronized
fun getCheckmarks(habitId: Long): IntArray { fun getCheckmarks(habitID: Long): IntArray {
return data.checkmarks[habitId]!! return data.checkmarks[habitID]!!
} }
@Synchronized @Synchronized
fun getNotes(habitId: Long): Array<String> { fun getNotes(habitID: Long): Array<String> {
return data.notes[habitId]!! return data.notes[habitID]!!
} }
@Synchronized @Synchronized
fun hasNoHabit(): Boolean { fun hasNoHabit(): Boolean {
return allHabits.isEmpty return habits.isEmpty
}
@Synchronized
fun hasNoHabitGroup(): Boolean {
return habitGroups.isEmpty
}
@Synchronized
fun hasNoSubHabits(): Boolean {
return habitGroups.all { it.habitList.isEmpty }
} }
/** /**
* Returns the habits that occupies a certain position on the list. * Returns the habits that occupies a certain position on the list.
* *
* @param position the position of the habit * @param position the position of the list of habits and groups
* @return the habit at given position or null if position is invalid * @return the habit at given position or null if position is invalid
*/ */
@Synchronized @Synchronized
fun getHabitByPosition(position: Int): Habit? { fun getHabitByPosition(position: Int): Habit? {
return if (position < 0 || position >= data.habits.size) null else data.habits[position] return data.positionToHabit[position]
}
/**
* Returns the habit groups that occupies a certain position on the list.
*
* @param position the position of the list of habits and groups
* @return the habit group at given position or null if position is invalid
*/
@Synchronized
fun getHabitGroupByPosition(position: Int): HabitGroup? {
return data.positionToHabitGroup[position]
}
@Synchronized
fun getIdByPosition(position: Int): Long? {
return if (data.positionTypes[position] == STANDALONE_HABIT || data.positionTypes[position] == SUB_HABIT) {
data.positionToHabit[position]!!.id
} else {
data.positionToHabitGroup[position]!!.id
}
} }
@get:Synchronized
val itemCount: Int
get() = habitCount + habitGroupCount + subHabitCount
@get:Synchronized @get:Synchronized
val habitCount: Int val habitCount: Int
get() = data.habits.size get() = data.habits.size
@get:Synchronized
val habitGroupCount: Int
get() = data.habitGroups.size
@get:Synchronized
val subHabitCount: Int
get() = data.subHabits.sumOf { it.size }
@get:Synchronized @get:Synchronized
@set:Synchronized @set:Synchronized
var primaryOrder: Order var primaryOrder: Order
get() = filteredHabits.primaryOrder get() = filteredHabits.primaryOrder
set(order) { set(order) {
allHabits.primaryOrder = order habits.primaryOrder = order
habitGroups.primaryOrder = order
filteredHabits.primaryOrder = order filteredHabits.primaryOrder = order
filteredHabitGroups.primaryOrder = order
refreshAllHabits() refreshAllHabits()
} }
@ -118,14 +164,16 @@ class HabitCardListCache @Inject constructor(
var secondaryOrder: Order var secondaryOrder: Order
get() = filteredHabits.secondaryOrder get() = filteredHabits.secondaryOrder
set(order) { set(order) {
allHabits.secondaryOrder = order habits.secondaryOrder = order
habitGroups.secondaryOrder = order
filteredHabits.secondaryOrder = order filteredHabits.secondaryOrder = order
filteredHabitGroups.secondaryOrder = order
refreshAllHabits() refreshAllHabits()
} }
@Synchronized @Synchronized
fun getScore(habitId: Long): Double { fun getScore(id: Long): Double {
return data.scores[habitId]!! return data.scores[id]!!
} }
@Synchronized @Synchronized
@ -163,22 +211,59 @@ class HabitCardListCache @Inject constructor(
@Synchronized @Synchronized
fun remove(id: Long) { fun remove(id: Long) {
val h = data.idToHabit[id] ?: return val position = data.idToPosition[id] ?: return
val position = data.habits.indexOf(h) val type = data.positionTypes[position]
data.habits.removeAt(position) if (type == STANDALONE_HABIT) {
data.idToHabit.remove(id) val h = data.idToHabit[id]
data.checkmarks.remove(id) if (h != null) {
data.notes.remove(id) data.habits.removeAt(position)
data.scores.remove(id) data.removeWithID(id)
listener.onItemRemoved(position) data.removeWithPos(position)
data.decrementPositions(position + 1, data.positionTypes.size)
listener.onItemRemoved(position)
}
} else if (type == SUB_HABIT) {
val h = data.idToHabit[id]
if (h != null) {
val hgrID = h.groupId
val hgr = data.idToHabitGroup[hgrID]
val hgrIdx = data.habitGroups.indexOf(hgr)
data.subHabits[hgrIdx].remove(h)
data.removeWithID(id)
data.removeWithPos(position)
data.decrementPositions(position + 1, data.positionTypes.size)
listener.onItemRemoved(position)
}
} else if (type == HABIT_GROUP) {
val hgr = data.idToHabitGroup[id]
if (hgr != null) {
val hgrIdx = data.positionIndices[position]
for (habit in data.subHabits[hgrIdx].reversed()) {
val habitPos = data.idToPosition[habit.id]!!
data.removeWithID(habit.id)
listener.onItemRemoved(habitPos)
}
data.subHabits.removeAt(hgrIdx)
data.habitGroups.removeAt(hgrIdx)
data.removeWithID(hgr.id)
data.rebuildPositions()
listener.onItemRemoved(position)
}
}
} }
@Synchronized @Synchronized
fun reorder(from: Int, to: Int) { fun reorder(from: Int, to: Int) {
val fromHabit = data.habits[from] if (from == to) return
data.habits.removeAt(from) val type = data.positionTypes[from]
data.habits.add(to, fromHabit) if (type == STANDALONE_HABIT || type == SUB_HABIT) {
listener.onItemMoved(from, to) val habit = data.positionToHabit[from]!!
data.performMove(habit, from, to)
} else {
val habitGroup = data.positionToHabitGroup[from]!!
data.performMove(habitGroup, from, to)
}
} }
@Synchronized @Synchronized
@ -188,7 +273,8 @@ class HabitCardListCache @Inject constructor(
@Synchronized @Synchronized
fun setFilter(matcher: HabitMatcher) { fun setFilter(matcher: HabitMatcher) {
filteredHabits = allHabits.getFiltered(matcher) filteredHabits = habits.getFiltered(matcher)
filteredHabitGroups = habitGroups.getFiltered(matcher)
} }
@Synchronized @Synchronized
@ -210,7 +296,15 @@ class HabitCardListCache @Inject constructor(
private inner class CacheData { private inner class CacheData {
val idToHabit: HashMap<Long?, Habit> = HashMap() val idToHabit: HashMap<Long?, Habit> = HashMap()
val idToHabitGroup: HashMap<Long?, HabitGroup> = HashMap()
val habits: MutableList<Habit> val habits: MutableList<Habit>
val habitGroups: MutableList<HabitGroup>
val subHabits: MutableList<MutableList<Habit>>
val idToPosition: HashMap<Long?, Int>
val positionTypes: MutableList<Int>
val positionIndices: MutableList<Int>
val positionToHabit: HashMap<Int, Habit>
val positionToHabitGroup: HashMap<Int, HabitGroup>
val checkmarks: HashMap<Long?, IntArray> val checkmarks: HashMap<Long?, IntArray>
val scores: HashMap<Long?, Double> val scores: HashMap<Long?, Double>
val notes: HashMap<Long?, Array<String>> val notes: HashMap<Long?, Array<String>>
@ -243,12 +337,20 @@ class HabitCardListCache @Inject constructor(
@Synchronized @Synchronized
fun copyScoresFrom(oldData: CacheData) { fun copyScoresFrom(oldData: CacheData) {
for (id in idToHabit.keys) { for (uuid in idToHabit.keys) {
if (oldData.scores.containsKey(id)) { if (oldData.scores.containsKey(uuid)) {
scores[id] = scores[uuid] =
oldData.scores[id]!! oldData.scores[uuid]!!
} else {
scores[uuid] = 0.0
}
}
for (uuid in idToHabitGroup.keys) {
if (oldData.scores.containsKey(uuid)) {
scores[uuid] =
oldData.scores[uuid]!!
} else { } else {
scores[id] = 0.0 scores[uuid] = 0.0
} }
} }
} }
@ -256,17 +358,221 @@ class HabitCardListCache @Inject constructor(
@Synchronized @Synchronized
fun fetchHabits() { fun fetchHabits() {
for (h in filteredHabits) { for (h in filteredHabits) {
if (h.id == null) continue if (h.uuid == null) continue
habits.add(h) habits.add(h)
}
for (hgr in filteredHabitGroups) {
if (hgr.uuid == null) continue
habitGroups.add(hgr)
val habitList = LinkedList<Habit>()
for (h in hgr.habitList) {
habitList.add(h)
}
subHabits.add(habitList)
}
}
@Synchronized
fun rebuildPositions() {
positionToHabit.clear()
positionToHabitGroup.clear()
idToPosition.clear()
positionTypes.clear()
positionIndices.clear()
var position = 0
for ((idx, h) in habits.withIndex()) {
idToHabit[h.id] = h idToHabit[h.id] = h
idToPosition[h.id] = position
positionToHabit[position] = h
positionTypes.add(STANDALONE_HABIT)
positionIndices.add(idx)
position++
}
for ((idx, hgr) in habitGroups.withIndex()) {
idToHabitGroup[hgr.id] = hgr
idToPosition[hgr.id] = position
positionToHabitGroup[position] = hgr
positionTypes.add(HABIT_GROUP)
positionIndices.add(idx)
val habitList = subHabits[idx]
position++
for ((hIdx, h) in habitList.withIndex()) {
idToHabit[h.id] = h
idToPosition[h.id] = position
positionToHabit[position] = h
positionTypes.add(SUB_HABIT)
positionIndices.add(hIdx)
position++
}
}
}
@Synchronized
fun isValidInsert(habit: Habit, position: Int): Boolean {
if (habit.groupId == null) {
return position <= habits.size
} else {
val parent = idToHabitGroup[habit.groupId] ?: return false
val parentPosition = idToPosition[habit.groupId]!!
val parentIndex = habitGroups.indexOf(parent)
val nextGroup = habitGroups.getOrNull(parentIndex + 1)
val nextGroupPosition = idToPosition[nextGroup?.id]
return (position > parentPosition && position <= positionTypes.size) && (nextGroupPosition == null || position <= nextGroupPosition)
} }
} }
@Synchronized
fun isValidInsert(habitGroup: HabitGroup, position: Int): Boolean {
return (position == positionTypes.size) || (positionTypes[position] == HABIT_GROUP)
}
@Synchronized
fun incrementPositions(from: Int, to: Int) {
for (pos in positionToHabit.keys.sortedDescending()) {
if (pos in from..to) {
positionToHabit[pos + 1] = positionToHabit[pos]!!
positionToHabit.remove(pos)
}
}
for (pos in positionToHabitGroup.keys.sortedDescending()) {
if (pos in from..to) {
positionToHabitGroup[pos + 1] = positionToHabitGroup[pos]!!
positionToHabitGroup.remove(pos)
}
}
for ((key, pos) in idToPosition.entries) {
if (pos in from..to) {
idToPosition[key] = pos + 1
}
}
}
@Synchronized
fun decrementPositions(fromPosition: Int, toPosition: Int) {
for (pos in positionToHabit.keys.sorted()) {
if (pos in fromPosition..toPosition) {
positionToHabit[pos - 1] = positionToHabit[pos]!!
positionToHabit.remove(pos)
}
}
for (pos in positionToHabitGroup.keys.sorted()) {
if (pos in fromPosition..toPosition) {
positionToHabitGroup[pos - 1] = positionToHabitGroup[pos]!!
positionToHabitGroup.remove(pos)
}
}
for ((key, pos) in idToPosition.entries) {
if (pos in fromPosition..toPosition) {
idToPosition[key] = pos - 1
}
}
}
@Synchronized
fun performMove(
habit: Habit,
fromPosition: Int,
toPosition: Int
) {
val type = positionTypes[fromPosition]
if (type == HABIT_GROUP) return
// Workaround for https://github.com/iSoron/uhabits/issues/968
val checkedToPosition = if (toPosition >= positionTypes.size) {
logger.error("performMove: $toPosition for habit is strictly higher than ${habits.size}")
positionTypes.size - 1
} else {
toPosition
}
val verifyPosition = if (fromPosition > checkedToPosition) checkedToPosition else checkedToPosition + 1
if (!isValidInsert(habit, verifyPosition)) return
if (type == STANDALONE_HABIT) {
habits.removeAt(fromPosition)
removeWithPos(fromPosition)
if (fromPosition < checkedToPosition) {
decrementPositions(fromPosition + 1, checkedToPosition)
} else {
incrementPositions(checkedToPosition, fromPosition - 1)
}
habits.add(checkedToPosition, habit)
positionTypes.add(checkedToPosition, STANDALONE_HABIT)
positionIndices.add(checkedToPosition, checkedToPosition)
} else {
val hgr = idToHabitGroup[habit.groupId]
val hgrIdx = habitGroups.indexOf(hgr)
val fromIdx = positionIndices[fromPosition]
subHabits[hgrIdx].removeAt(fromIdx)
removeWithPos(fromPosition)
if (fromPosition < checkedToPosition) {
decrementPositions(fromPosition + 1, checkedToPosition)
} else {
incrementPositions(checkedToPosition, fromPosition - 1)
}
val toIdx = checkedToPosition - idToPosition[hgr!!.id]!! - 1
subHabits[hgrIdx].add(toIdx, habit)
positionTypes.add(checkedToPosition, SUB_HABIT)
positionIndices.add(checkedToPosition, toIdx)
}
positionToHabit[checkedToPosition] = habit
idToPosition[habit.id] = checkedToPosition
listener.onItemMoved(fromPosition, checkedToPosition)
}
@Synchronized
fun performMove(
habitGroup: HabitGroup,
fromPosition: Int,
toPosition: Int
) {
if (positionTypes[fromPosition] != HABIT_GROUP) return
if (!isValidInsert(habitGroup, toPosition)) return
val fromIdx = positionIndices[fromPosition]
val habitList = subHabits[fromIdx]
val toIdx = habitGroups.indexOf(positionToHabitGroup[toPosition])
habitGroups.removeAt(fromIdx)
subHabits.removeAt(fromIdx)
habitGroups.add(toIdx, habitGroup)
subHabits.add(toIdx, habitList)
rebuildPositions()
listener.onItemMoved(fromPosition, toPosition)
}
fun removeWithID(id: Long?) {
idToPosition.remove(id)
idToHabit.remove(id)
idToHabitGroup.remove(id)
scores.remove(id)
notes.remove(id)
checkmarks.remove(id)
}
fun removeWithPos(pos: Int) {
positionTypes.removeAt(pos)
positionIndices.removeAt(pos)
positionToHabit.remove(pos)
}
/** /**
* Creates a new CacheData without any content. * Creates a new CacheData without any content.
*/ */
init { init {
habits = LinkedList() habits = LinkedList()
habitGroups = LinkedList()
subHabits = LinkedList()
positionTypes = LinkedList()
positionIndices = LinkedList()
idToPosition = HashMap()
positionToHabit = HashMap()
positionToHabitGroup = HashMap()
checkmarks = HashMap() checkmarks = HashMap()
scores = HashMap() scores = HashMap()
notes = HashMap() notes = HashMap()
@ -275,19 +581,19 @@ class HabitCardListCache @Inject constructor(
private inner class RefreshTask : Task { private inner class RefreshTask : Task {
private val newData: CacheData private val newData: CacheData
private val targetId: Long? private val targetID: Long?
private var isCancelled = false private var isCancelled = false
private var runner: TaskRunner? = null private var runner: TaskRunner? = null
constructor() { constructor() {
newData = CacheData() newData = CacheData()
targetId = null targetID = null
isCancelled = false isCancelled = false
} }
constructor(targetId: Long) { constructor(targetID: Long) {
newData = CacheData() newData = CacheData()
this.targetId = targetId this.targetID = targetID
} }
@Synchronized @Synchronized
@ -298,27 +604,35 @@ class HabitCardListCache @Inject constructor(
@Synchronized @Synchronized
override fun doInBackground() { override fun doInBackground() {
newData.fetchHabits() newData.fetchHabits()
newData.rebuildPositions()
newData.copyScoresFrom(data) newData.copyScoresFrom(data)
newData.copyCheckmarksFrom(data) newData.copyCheckmarksFrom(data)
newData.copyNoteIndicatorsFrom(data) newData.copyNoteIndicatorsFrom(data)
val today = getTodayWithOffset() val today = getTodayWithOffset()
val dateFrom = today.minus(checkmarkCount - 1) val dateFrom = today.minus(checkmarkCount - 1)
if (runner != null) runner!!.publishProgress(this, -1) if (runner != null) runner!!.publishProgress(this, -1)
for (position in newData.habits.indices) { for ((position, type) in newData.positionTypes.withIndex()) {
if (isCancelled) return if (isCancelled) return
val habit = newData.habits[position] if (type == STANDALONE_HABIT || type == SUB_HABIT) {
if (targetId != null && targetId != habit.id) continue val habit = newData.positionToHabit[position]!!
newData.scores[habit.id] = habit.scores[today].value if (targetID != null && targetID != habit.id) continue
val list: MutableList<Int> = ArrayList() newData.scores[habit.id] = habit.scores[today].value
val notes: MutableList<String> = ArrayList() val list: MutableList<Int> = ArrayList()
for ((_, value, note) in habit.computedEntries.getByInterval(dateFrom, today)) { val notes: MutableList<String> = ArrayList()
list.add(value) for ((_, value, note) in habit.computedEntries.getByInterval(dateFrom, today)) {
notes.add(note) list.add(value)
notes.add(note)
}
val entries = list.toTypedArray()
newData.checkmarks[habit.id] = ArrayUtils.toPrimitive(entries)
newData.notes[habit.id] = notes.toTypedArray()
runner!!.publishProgress(this, position)
} else if (type == HABIT_GROUP) {
val habitGroup = newData.positionToHabitGroup[position]!!
if (targetID != null && targetID != habitGroup.id) continue
newData.scores[habitGroup.id] = habitGroup.scores[today].value
runner!!.publishProgress(this, position)
} }
val entries = list.toTypedArray()
newData.checkmarks[habit.id] = ArrayUtils.toPrimitive(entries)
newData.notes[habit.id] = notes.toTypedArray()
runner!!.publishProgress(this, position)
} }
} }
@ -340,8 +654,23 @@ class HabitCardListCache @Inject constructor(
@Synchronized @Synchronized
private fun performInsert(habit: Habit, position: Int) { private fun performInsert(habit: Habit, position: Int) {
if (!data.isValidInsert(habit, position)) return
val id = habit.id val id = habit.id
data.habits.add(position, habit) if (habit.groupId == null) {
data.habits.add(position, habit)
data.positionTypes.add(position, STANDALONE_HABIT)
data.positionIndices.add(position, position)
} else {
val hgrPos = data.idToPosition[habit.groupId]!!
val hgrIdx = data.positionIndices[hgrPos]
val habitIndex = newData.positionIndices[position]
data.subHabits[hgrIdx].add(habitIndex, habit)
data.positionTypes.add(position, SUB_HABIT)
data.positionIndices.add(position, habitIndex)
}
data.incrementPositions(position, data.positionTypes.size - 1)
data.positionToHabit[position] = habit
data.idToPosition[id] = position
data.idToHabit[id] = habit data.idToHabit[id] = habit
data.scores[id] = newData.scores[id]!! data.scores[id] = newData.scores[id]!!
data.checkmarks[id] = newData.checkmarks[id]!! data.checkmarks[id] = newData.checkmarks[id]!!
@ -350,76 +679,119 @@ class HabitCardListCache @Inject constructor(
} }
@Synchronized @Synchronized
private fun performMove( private fun performInsert(habitGroup: HabitGroup, position: Int) {
habit: Habit, if (!data.isValidInsert(habitGroup, position)) return
fromPosition: Int, val id = habitGroup.id
toPosition: Int val prevIdx = newData.positionIndices[position]
) { val habitList = newData.subHabits[prevIdx]
data.habits.removeAt(fromPosition) val idx = if (position < data.positionIndices.size) {
data.positionIndices[position]
// Workaround for https://github.com/iSoron/uhabits/issues/968
val checkedToPosition = if (toPosition > data.habits.size) {
logger.error("performMove: $toPosition is strictly higher than ${data.habits.size}")
data.habits.size
} else { } else {
toPosition data.habitGroups.size
} }
data.habits.add(checkedToPosition, habit) data.habitGroups.add(idx, habitGroup)
listener.onItemMoved(fromPosition, checkedToPosition) data.subHabits.add(idx, habitList)
data.scores[id] = newData.scores[id]!!
for (h in habitList) {
data.scores[h.id] = newData.scores[h.id]!!
data.checkmarks[h.id] = newData.checkmarks[h.id]!!
data.notes[h.id] = newData.notes[h.id]!!
}
data.rebuildPositions()
listener.onItemInserted(position)
} }
@Synchronized @Synchronized
private fun performUpdate(id: Long, position: Int) { private fun performUpdate(id: Long, position: Int) {
var unchanged = true
val oldScore = data.scores[id]!! val oldScore = data.scores[id]!!
val oldCheckmarks = data.checkmarks[id]
val oldNoteIndicators = data.notes[id]
val newScore = newData.scores[id]!! val newScore = newData.scores[id]!!
val newCheckmarks = newData.checkmarks[id]!!
val newNoteIndicators = newData.notes[id]!!
var unchanged = true
if (oldScore != newScore) unchanged = false if (oldScore != newScore) unchanged = false
if (!Arrays.equals(oldCheckmarks, newCheckmarks)) unchanged = false
if (!Arrays.equals(oldNoteIndicators, newNoteIndicators)) unchanged = false if (data.positionTypes[position] != HABIT_GROUP) {
val oldCheckmarks = data.checkmarks[id]
val newCheckmarks = newData.checkmarks[id]!!
val oldNoteIndicators = data.notes[id]
val newNoteIndicators = newData.notes[id]!!
if (!Arrays.equals(oldCheckmarks, newCheckmarks)) unchanged = false
if (!Arrays.equals(oldNoteIndicators, newNoteIndicators)) unchanged = false
if (unchanged) return
data.checkmarks[id] = newCheckmarks
data.notes[id] = newNoteIndicators
}
if (unchanged) return if (unchanged) return
data.scores[id] = newScore data.scores[id] = newScore
data.checkmarks[id] = newCheckmarks
data.notes[id] = newNoteIndicators
listener.onItemChanged(position) listener.onItemChanged(position)
} }
@Synchronized @Synchronized
private fun processPosition(currentPosition: Int) { private fun processPosition(currentPosition: Int) {
val habit = newData.habits[currentPosition] val type = newData.positionTypes[currentPosition]
val id = habit.id
val prevPosition = data.habits.indexOf(habit) if (type == STANDALONE_HABIT || type == SUB_HABIT) {
if (prevPosition < 0) { val habit = newData.positionToHabit[currentPosition]!!
performInsert(habit, currentPosition) val id = habit.id ?: throw NullPointerException()
} else { val prevPosition = data.idToPosition[id] ?: -1
if (prevPosition != currentPosition) { val newPosition = if (type == STANDALONE_HABIT) {
performMove( currentPosition
habit, } else {
prevPosition, val hgrPos = data.idToPosition[habit.groupId]!!
currentPosition val hgrIdx = data.positionIndices[hgrPos]
) newData.subHabits[hgrIdx].indexOf(habit) + hgrPos + 1
}
if (prevPosition < 0) {
performInsert(habit, newPosition)
} else {
if (prevPosition != newPosition) {
data.performMove(
habit,
prevPosition,
newPosition
)
}
performUpdate(id, currentPosition)
}
} else if (type == HABIT_GROUP) {
val habitGroup = newData.positionToHabitGroup[currentPosition]!!
val id = habitGroup.id ?: throw NullPointerException()
val prevPosition = data.idToPosition[id] ?: -1
if (prevPosition < 0) {
performInsert(habitGroup, currentPosition)
} else {
if (prevPosition != currentPosition) {
data.performMove(
habitGroup,
prevPosition,
currentPosition
)
}
performUpdate(id, currentPosition)
} }
if (id == null) throw NullPointerException()
performUpdate(id, currentPosition)
} }
} }
@Synchronized @Synchronized
private fun processRemovedHabits() { private fun processRemovedHabits() {
val before: Set<Long?> = data.idToHabit.keys val before: Set<Long?> = (data.idToHabit.keys).union(data.idToHabitGroup.keys)
val after: Set<Long?> = newData.idToHabit.keys val after: Set<Long?> = (newData.idToHabit.keys).union(newData.idToHabitGroup.keys)
val removed: MutableSet<Long?> = TreeSet(before) val removed: MutableSet<Long?> = TreeSet(before)
removed.removeAll(after) removed.removeAll(after)
for (id in removed) remove(id!!) for (id in removed.sortedBy { data.idToPosition[it] }) remove(id!!)
} }
} }
companion object {
const val STANDALONE_HABIT = 0
const val HABIT_GROUP = 1
const val SUB_HABIT = 2
}
init { init {
filteredHabits = allHabits filteredHabits = habits
filteredHabitGroups = habitGroups
this.taskRunner = taskRunner this.taskRunner = taskRunner
listener = object : Listener {} listener = object : Listener {}
data = CacheData() data = CacheData()

@ -20,8 +20,11 @@ package org.isoron.uhabits.core.ui.screens.habits.list
import org.isoron.uhabits.core.commands.CommandRunner import org.isoron.uhabits.core.commands.CommandRunner
import org.isoron.uhabits.core.commands.CreateRepetitionCommand import org.isoron.uhabits.core.commands.CreateRepetitionCommand
import org.isoron.uhabits.core.commands.RefreshParentGroupCommand
import org.isoron.uhabits.core.models.Entry.Companion.YES_MANUAL import org.isoron.uhabits.core.models.Entry.Companion.YES_MANUAL
import org.isoron.uhabits.core.models.Habit 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.HabitList
import org.isoron.uhabits.core.models.HabitType import org.isoron.uhabits.core.models.HabitType
import org.isoron.uhabits.core.models.NumericalHabitType.AT_LEAST import org.isoron.uhabits.core.models.NumericalHabitType.AT_LEAST
@ -40,6 +43,7 @@ import kotlin.math.roundToInt
open class ListHabitsBehavior @Inject constructor( open class ListHabitsBehavior @Inject constructor(
private val habitList: HabitList, private val habitList: HabitList,
private val habitGroupList: HabitGroupList,
private val dirFinder: DirFinder, private val dirFinder: DirFinder,
private val taskRunner: TaskRunner, private val taskRunner: TaskRunner,
private val screen: Screen, private val screen: Screen,
@ -51,7 +55,12 @@ open class ListHabitsBehavior @Inject constructor(
screen.showHabitScreen(h) screen.showHabitScreen(h)
} }
fun onClickHabitGroup(hgr: HabitGroup) {
screen.showHabitGroupScreen(hgr)
}
fun onEdit(habit: Habit, timestamp: Timestamp?) { fun onEdit(habit: Habit, timestamp: Timestamp?) {
val list = if (habit.isSubHabit()) habit.group!!.habitList else habitList
val entry = habit.computedEntries.get(timestamp!!) val entry = habit.computedEntries.get(timestamp!!)
if (habit.type == HabitType.NUMERICAL) { if (habit.type == HabitType.NUMERICAL) {
val oldValue = entry.value.toDouble() / 1000 val oldValue = entry.value.toDouble() / 1000
@ -65,7 +74,8 @@ open class ListHabitsBehavior @Inject constructor(
screen.showConfetti(habit.color, x, y) screen.showConfetti(habit.color, x, y)
} }
} }
commandRunner.run(CreateRepetitionCommand(habitList, habit, timestamp, value, newNotes)) commandRunner.run(CreateRepetitionCommand(list, habit, timestamp, value, newNotes))
commandRunner.run(RefreshParentGroupCommand(habit, habitGroupList))
} }
} else { } else {
screen.showCheckmarkPopup( screen.showCheckmarkPopup(
@ -74,7 +84,8 @@ open class ListHabitsBehavior @Inject constructor(
habit.color habit.color
) { newValue: Int, newNotes: String, x: Float, y: Float -> ) { newValue: Int, newNotes: String, x: Float, y: Float ->
if (newValue != entry.value && newValue == YES_MANUAL) screen.showConfetti(habit.color, x, y) if (newValue != entry.value && newValue == YES_MANUAL) screen.showConfetti(habit.color, x, y)
commandRunner.run(CreateRepetitionCommand(habitList, habit, timestamp, newValue, newNotes)) commandRunner.run(CreateRepetitionCommand(list, habit, timestamp, newValue, newNotes))
commandRunner.run(RefreshParentGroupCommand(habit, habitGroupList))
} }
} }
} }
@ -103,7 +114,14 @@ open class ListHabitsBehavior @Inject constructor(
} }
fun onReorderHabit(from: Habit, to: Habit) { fun onReorderHabit(from: Habit, to: Habit) {
taskRunner.execute { habitList.reorder(from, to) } if (from.group == to.group) {
val list = from.group?.habitList ?: habitList
taskRunner.execute { list.reorder(from, to) }
}
}
fun onReorderHabitGroup(from: HabitGroup, to: HabitGroup) {
taskRunner.execute { habitGroupList.reorder(from, to) }
} }
fun onRepairDB() { fun onRepairDB() {
@ -130,8 +148,12 @@ open class ListHabitsBehavior @Inject constructor(
} }
fun onToggle(habit: Habit, timestamp: Timestamp, value: Int, notes: String, x: Float, y: Float) { fun onToggle(habit: Habit, timestamp: Timestamp, value: Int, notes: String, x: Float, y: Float) {
val list = if (habit.isSubHabit()) habit.group!!.habitList else habitList
commandRunner.run(
CreateRepetitionCommand(list, habit, timestamp, value, notes)
)
commandRunner.run( commandRunner.run(
CreateRepetitionCommand(habitList, habit, timestamp, value, notes) RefreshParentGroupCommand(habit, habitGroupList)
) )
if (value == YES_MANUAL) screen.showConfetti(habit.color, x, y) if (value == YES_MANUAL) screen.showConfetti(habit.color, x, y)
} }
@ -178,6 +200,7 @@ open class ListHabitsBehavior @Inject constructor(
interface Screen { interface Screen {
fun showHabitScreen(h: Habit) fun showHabitScreen(h: Habit)
fun showHabitGroupScreen(hgr: HabitGroup)
fun showIntroScreen() fun showIntroScreen()
fun showMessage(m: Message) fun showMessage(m: Message)
fun showNumberPopup( fun showNumberPopup(

@ -33,8 +33,8 @@ class ListHabitsMenuBehavior @Inject constructor(
private var showCompleted: Boolean private var showCompleted: Boolean
private var showArchived: Boolean private var showArchived: Boolean
fun onCreateHabit() { fun onCreateHabit(groupId: Long? = null) {
screen.showSelectHabitTypeDialog() screen.showSelectHabitTypeDialog(groupId)
} }
fun onViewFAQ() { fun onViewFAQ() {
@ -132,7 +132,7 @@ class ListHabitsMenuBehavior @Inject constructor(
fun showAboutScreen() fun showAboutScreen()
fun showFAQScreen() fun showFAQScreen()
fun showSettingsScreen() fun showSettingsScreen()
fun showSelectHabitTypeDialog() fun showSelectHabitTypeDialog(groupId: Long? = null)
} }
init { init {

@ -18,12 +18,20 @@
*/ */
package org.isoron.uhabits.core.ui.screens.habits.list package org.isoron.uhabits.core.ui.screens.habits.list
import org.isoron.uhabits.core.commands.ArchiveHabitGroupsCommand
import org.isoron.uhabits.core.commands.ArchiveHabitsCommand import org.isoron.uhabits.core.commands.ArchiveHabitsCommand
import org.isoron.uhabits.core.commands.ChangeHabitColorCommand import org.isoron.uhabits.core.commands.ChangeHabitColorCommand
import org.isoron.uhabits.core.commands.ChangeHabitGroupColorCommand
import org.isoron.uhabits.core.commands.CommandRunner import org.isoron.uhabits.core.commands.CommandRunner
import org.isoron.uhabits.core.commands.DeleteHabitGroupsCommand
import org.isoron.uhabits.core.commands.DeleteHabitsCommand import org.isoron.uhabits.core.commands.DeleteHabitsCommand
import org.isoron.uhabits.core.commands.RefreshParentGroupCommand
import org.isoron.uhabits.core.commands.RemoveFromGroupCommand
import org.isoron.uhabits.core.commands.UnarchiveHabitGroupsCommand
import org.isoron.uhabits.core.commands.UnarchiveHabitsCommand import org.isoron.uhabits.core.commands.UnarchiveHabitsCommand
import org.isoron.uhabits.core.models.Habit 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.HabitList
import org.isoron.uhabits.core.models.PaletteColor import org.isoron.uhabits.core.models.PaletteColor
import org.isoron.uhabits.core.ui.callbacks.OnColorPickedCallback import org.isoron.uhabits.core.ui.callbacks.OnColorPickedCallback
@ -32,33 +40,67 @@ import javax.inject.Inject
class ListHabitsSelectionMenuBehavior @Inject constructor( class ListHabitsSelectionMenuBehavior @Inject constructor(
private val habitList: HabitList, private val habitList: HabitList,
private val habitGroupList: HabitGroupList,
private val screen: Screen, private val screen: Screen,
private val adapter: Adapter, private val adapter: Adapter,
var commandRunner: CommandRunner var commandRunner: CommandRunner
) { ) {
fun canArchive(): Boolean { fun canArchive(): Boolean {
for (habit in adapter.getSelected()) if (habit.isArchived) return false for (habit in adapter.getSelectedHabits()) if (habit.isArchived) return false
for (hgr in adapter.getSelectedHabitGroups()) if (hgr.isArchived) return false
return true return true
} }
fun canEdit(): Boolean { fun canEdit(): Boolean {
return adapter.getSelected().size == 1 return (adapter.getSelectedHabits().size + adapter.getSelectedHabitGroups().size == 1)
} }
fun canUnarchive(): Boolean { fun canUnarchive(): Boolean {
for (habit in adapter.getSelected()) if (!habit.isArchived) return false for (habit in adapter.getSelectedHabits()) if (!habit.isArchived) return false
for (hgr in adapter.getSelectedHabitGroups()) if (!hgr.isArchived) return false
return true return true
} }
fun areSubHabits(): Boolean {
if (adapter.getSelectedHabitGroups().isNotEmpty()) return false
return (adapter.getSelectedHabits().all { it.isSubHabit() })
}
fun areHabits(): Boolean {
return adapter.getSelectedHabitGroups().isEmpty()
}
fun onArchiveHabits() { fun onArchiveHabits() {
commandRunner.run(ArchiveHabitsCommand(habitList, adapter.getSelected())) commandRunner.run(ArchiveHabitsCommand(habitList, adapter.getSelectedHabits()))
commandRunner.run(ArchiveHabitGroupsCommand(habitGroupList, adapter.getSelectedHabitGroups()))
for (habit in adapter.getSelectedHabits()) {
commandRunner.run(RefreshParentGroupCommand(habit, habitGroupList))
}
adapter.clearSelection() adapter.clearSelection()
} }
fun onChangeColor() { fun onChangeColor() {
val (color) = adapter.getSelected()[0] val color = if (adapter.getSelectedHabits().isNotEmpty()) {
adapter.getSelectedHabits()[0].color
} else {
adapter.getSelectedHabitGroups()[0].color
}
screen.showColorPicker(color) { selectedColor: PaletteColor -> screen.showColorPicker(color) { selectedColor: PaletteColor ->
commandRunner.run(ChangeHabitColorCommand(habitList, adapter.getSelected(), selectedColor)) commandRunner.run(
ChangeHabitColorCommand(
habitList,
adapter.getSelectedHabits(),
selectedColor
)
)
commandRunner.run(
ChangeHabitGroupColorCommand(
habitGroupList,
adapter.getSelectedHabitGroups(),
selectedColor
)
)
adapter.clearSelection() adapter.clearSelection()
} }
} }
@ -66,29 +108,58 @@ class ListHabitsSelectionMenuBehavior @Inject constructor(
fun onDeleteHabits() { fun onDeleteHabits() {
screen.showDeleteConfirmationScreen( screen.showDeleteConfirmationScreen(
{ {
adapter.performRemove(adapter.getSelected()) adapter.performRemove(adapter.getSelectedHabits())
commandRunner.run(DeleteHabitsCommand(habitList, adapter.getSelected())) adapter.performRemoveHabitGroup(adapter.getSelectedHabitGroups())
commandRunner.run(DeleteHabitGroupsCommand(habitGroupList, adapter.getSelectedHabitGroups()))
commandRunner.run(DeleteHabitsCommand(habitList, adapter.getSelectedHabits()))
for (habit in adapter.getSelectedHabits()) {
commandRunner.run(RefreshParentGroupCommand(habit, habitGroupList))
}
adapter.clearSelection() adapter.clearSelection()
}, },
adapter.getSelected().size adapter.getSelectedHabits().size + adapter.getSelectedHabitGroups().size
) )
} }
fun onEditHabits() { fun onEditHabits() {
val selected = adapter.getSelected() val selected = adapter.getSelectedHabits()
if (selected.isNotEmpty()) screen.showEditHabitsScreen(selected) if (selected.isNotEmpty()) {
screen.showEditHabitsScreen(selected)
} else {
val selectedGroup = adapter.getSelectedHabitGroups()
screen.showEditHabitGroupScreen(selectedGroup)
}
adapter.clearSelection() adapter.clearSelection()
} }
fun onUnarchiveHabits() { fun onUnarchiveHabits() {
commandRunner.run(UnarchiveHabitsCommand(habitList, adapter.getSelected())) commandRunner.run(UnarchiveHabitsCommand(habitList, adapter.getSelectedHabits()))
commandRunner.run(UnarchiveHabitGroupsCommand(habitGroupList, adapter.getSelectedHabitGroups()))
for (habit in adapter.getSelectedHabits()) {
commandRunner.run(RefreshParentGroupCommand(habit, habitGroupList))
}
adapter.clearSelection()
}
fun onRemoveFromGroup() {
adapter.performRemove(adapter.getSelectedHabits())
commandRunner.run(RemoveFromGroupCommand(habitList, adapter.getSelectedHabits()))
adapter.clearSelection()
}
fun onAddToGroup() {
adapter.performRemove(adapter.getSelectedHabits())
screen.showHabitGroupPickerDialog(adapter.getSelectedHabits())
adapter.clearSelection() adapter.clearSelection()
} }
interface Adapter { interface Adapter {
fun clearSelection() fun clearSelection()
fun getSelected(): List<Habit> fun getSelectedHabits(): List<Habit>
fun getSelectedHabitGroups(): List<HabitGroup>
fun performRemove(selected: List<Habit>) fun performRemove(selected: List<Habit>)
fun performRemoveHabitGroup(selected: List<HabitGroup>)
} }
interface Screen { interface Screen {
@ -103,5 +174,8 @@ class ListHabitsSelectionMenuBehavior @Inject constructor(
) )
fun showEditHabitsScreen(selected: List<Habit>) fun showEditHabitsScreen(selected: List<Habit>)
fun showEditHabitGroupScreen(selected: List<HabitGroup>)
fun showHabitGroupPickerDialog(selected: List<Habit>)
} }
} }

@ -0,0 +1,117 @@
package org.isoron.uhabits.core.ui.screens.habits.show
import org.isoron.uhabits.core.commands.CommandRunner
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.ui.screens.habits.show.views.BarCardPresenter
import org.isoron.uhabits.core.ui.screens.habits.show.views.BarCardState
import org.isoron.uhabits.core.ui.screens.habits.show.views.FrequencyCardPresenter
import org.isoron.uhabits.core.ui.screens.habits.show.views.FrequencyCardState
import org.isoron.uhabits.core.ui.screens.habits.show.views.NotesCardPresenter
import org.isoron.uhabits.core.ui.screens.habits.show.views.NotesCardState
import org.isoron.uhabits.core.ui.screens.habits.show.views.OverviewCardPresenter
import org.isoron.uhabits.core.ui.screens.habits.show.views.OverviewCardState
import org.isoron.uhabits.core.ui.screens.habits.show.views.ScoreCardPresenter
import org.isoron.uhabits.core.ui.screens.habits.show.views.ScoreCardState
import org.isoron.uhabits.core.ui.screens.habits.show.views.StreakCardState
import org.isoron.uhabits.core.ui.screens.habits.show.views.StreakCartPresenter
import org.isoron.uhabits.core.ui.screens.habits.show.views.SubtitleCardPresenter
import org.isoron.uhabits.core.ui.screens.habits.show.views.SubtitleCardState
import org.isoron.uhabits.core.ui.screens.habits.show.views.TargetCardPresenter
import org.isoron.uhabits.core.ui.screens.habits.show.views.TargetCardState
import org.isoron.uhabits.core.ui.views.Theme
data class ShowHabitGroupState(
val title: String = "",
val isEmpty: Boolean = false,
val isNumerical: Boolean = false,
val isBoolean: Boolean = false,
val color: PaletteColor = PaletteColor(1),
val subtitle: SubtitleCardState,
val overview: OverviewCardState,
val notes: NotesCardState,
val target: TargetCardState,
val streaks: StreakCardState,
val scores: ScoreCardState,
val frequency: FrequencyCardState,
val bar: BarCardState,
val theme: Theme
)
class ShowHabitGroupPresenter(
val habitGroup: HabitGroup,
val preferences: Preferences,
val screen: Screen,
val commandRunner: CommandRunner
) {
val barCardPresenter = BarCardPresenter(
preferences = preferences,
screen = screen
)
val scoreCardPresenter = ScoreCardPresenter(
preferences = preferences,
screen = screen
)
companion object {
fun buildState(
habitGroup: HabitGroup,
preferences: Preferences,
theme: Theme
): ShowHabitGroupState {
return ShowHabitGroupState(
title = habitGroup.name,
isEmpty = habitGroup.habitList.isEmpty,
isNumerical = habitGroup.habitList.all { it.isNumerical },
isBoolean = habitGroup.habitList.all { !it.isNumerical },
color = habitGroup.color,
theme = theme,
subtitle = SubtitleCardPresenter.buildState(
habitGroup = habitGroup,
theme = theme
),
overview = OverviewCardPresenter.buildState(
habitGroup = habitGroup,
theme = theme
),
notes = NotesCardPresenter.buildState(
habitGroup = habitGroup
),
target = TargetCardPresenter.buildState(
habitGroup = habitGroup,
firstWeekday = preferences.firstWeekdayInt,
theme = theme
),
streaks = StreakCartPresenter.buildState(
habitGroup = habitGroup,
theme = theme
),
scores = ScoreCardPresenter.buildState(
spinnerPosition = preferences.scoreCardSpinnerPosition,
habitGroup = habitGroup,
firstWeekday = preferences.firstWeekdayInt,
theme = theme
),
frequency = FrequencyCardPresenter.buildState(
habitGroup = habitGroup,
firstWeekday = preferences.firstWeekdayInt,
theme = theme
),
bar = BarCardPresenter.buildState(
habitGroup = habitGroup,
firstWeekday = preferences.firstWeekdayInt,
boolSpinnerPosition = preferences.barCardBoolSpinnerPosition,
numericalSpinnerPosition = preferences.barCardNumericalSpinnerPosition,
theme = theme
)
)
}
}
interface Screen :
BarCardPresenter.Screen,
ScoreCardPresenter.Screen
}

@ -0,0 +1,43 @@
package org.isoron.uhabits.core.ui.screens.habits.show
import org.isoron.uhabits.core.commands.CommandRunner
import org.isoron.uhabits.core.commands.DeleteHabitGroupsCommand
import org.isoron.uhabits.core.models.HabitGroup
import org.isoron.uhabits.core.models.HabitGroupList
import org.isoron.uhabits.core.ui.callbacks.OnConfirmedCallback
import java.io.File
class ShowHabitGroupMenuPresenter(
private val commandRunner: CommandRunner,
private val habitGroup: HabitGroup,
private val habitGroupList: HabitGroupList,
private val screen: Screen
) {
fun onEditHabitGroup() {
screen.showEditHabitGroupScreen(habitGroup)
}
fun onDeleteHabitGroup() {
screen.showDeleteConfirmationScreen {
commandRunner.run(DeleteHabitGroupsCommand(habitGroupList, listOf(habitGroup)))
screen.close()
}
}
enum class Message {
COULD_NOT_EXPORT
}
interface Screen {
fun showEditHabitGroupScreen(habitGroup: HabitGroup)
fun showMessage(m: Message?)
fun showSendFileScreen(filename: String)
fun showDeleteConfirmationScreen(callback: OnConfirmedCallback)
fun close()
fun refresh()
}
interface System {
fun getCSVOutputDir(): File
}
}

@ -21,6 +21,7 @@ package org.isoron.uhabits.core.ui.screens.habits.show.views
import org.isoron.uhabits.core.models.Entry import org.isoron.uhabits.core.models.Entry
import org.isoron.uhabits.core.models.Habit 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.models.PaletteColor
import org.isoron.uhabits.core.models.groupedSum import org.isoron.uhabits.core.models.groupedSum
import org.isoron.uhabits.core.preferences.Preferences import org.isoron.uhabits.core.preferences.Preferences
@ -74,6 +75,53 @@ class BarCardPresenter(
boolSpinnerPosition = boolSpinnerPosition boolSpinnerPosition = boolSpinnerPosition
) )
} }
fun buildState(
habitGroup: HabitGroup,
firstWeekday: Int,
numericalSpinnerPosition: Int,
boolSpinnerPosition: Int,
theme: Theme
): BarCardState {
val isNumerical = habitGroup.habitList.all { it.isNumerical }
val isBoolean = habitGroup.habitList.all { !it.isNumerical }
if ((!isNumerical && !isBoolean) || habitGroup.habitList.isEmpty) {
return BarCardState(
theme = theme,
entries = listOf(Entry(DateUtils.getTodayWithOffset(), 0)),
bucketSize = 1,
color = habitGroup.color,
isNumerical = isNumerical,
numericalSpinnerPosition = numericalSpinnerPosition,
boolSpinnerPosition = boolSpinnerPosition
)
}
val bucketSize = if (isNumerical) {
numericalBucketSizes[numericalSpinnerPosition]
} else {
boolBucketSizes[boolSpinnerPosition]
}
val today = DateUtils.getTodayWithOffset()
val allEntries = habitGroup.habitList.map { habit ->
val oldest = habit.computedEntries.getKnown().lastOrNull()?.timestamp ?: today
habit.computedEntries.getByInterval(oldest, today).groupedSum(
truncateField = ScoreCardPresenter.getTruncateField(bucketSize),
firstWeekday = firstWeekday,
isNumerical = habit.isNumerical
)
}.flatten()
val summedEntries = allEntries.groupedSum()
return BarCardState(
theme = theme,
entries = summedEntries,
bucketSize = bucketSize,
color = habitGroup.color,
isNumerical = isNumerical,
numericalSpinnerPosition = numericalSpinnerPosition,
boolSpinnerPosition = boolSpinnerPosition
)
}
} }
fun onNumericalSpinnerPosition(position: Int) { fun onNumericalSpinnerPosition(position: Int) {

@ -20,10 +20,10 @@
package org.isoron.uhabits.core.ui.screens.habits.show.views package org.isoron.uhabits.core.ui.screens.habits.show.views
import org.isoron.uhabits.core.models.Habit 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.models.PaletteColor
import org.isoron.uhabits.core.models.Timestamp import org.isoron.uhabits.core.models.Timestamp
import org.isoron.uhabits.core.ui.views.Theme import org.isoron.uhabits.core.ui.views.Theme
import java.util.HashMap
data class FrequencyCardState( data class FrequencyCardState(
val color: PaletteColor, val color: PaletteColor,
@ -42,11 +42,57 @@ class FrequencyCardPresenter {
) = FrequencyCardState( ) = FrequencyCardState(
color = habit.color, color = habit.color,
isNumerical = habit.isNumerical, isNumerical = habit.isNumerical,
frequency = habit.originalEntries.computeWeekdayFrequency( frequency = habit.computedEntries.computeWeekdayFrequency(
isNumerical = habit.isNumerical isNumerical = habit.isNumerical
), ),
firstWeekday = firstWeekday, firstWeekday = firstWeekday,
theme = theme theme = theme
) )
fun buildState(
habitGroup: HabitGroup,
firstWeekday: Int,
theme: Theme
): FrequencyCardState {
val frequencies = if (habitGroup.habitList.isEmpty) {
hashMapOf<Timestamp, Array<Int>>()
} else {
getFrequenciesFromHabitGroup(habitGroup)
}
return FrequencyCardState(
color = habitGroup.color,
isNumerical = true,
frequency = frequencies,
firstWeekday = firstWeekday,
theme = theme
)
}
fun getFrequenciesFromHabitGroup(habitGroup: HabitGroup): HashMap<Timestamp, Array<Int>> {
val normalizedEntries = habitGroup.habitList.map {
it.computedEntries.normalizeEntries(it.isNumerical, it.frequency, it.targetValue)
}
val frequencies = normalizedEntries.map {
it.computeWeekdayFrequency(isNumerical = true)
}.reduce { acc, hashMap ->
mergeMaps(acc, hashMap) { value1, value2 -> addArray(value1, value2) }
}
return frequencies
}
private fun <K, V> mergeMaps(map1: HashMap<K, V>, map2: HashMap<K, V>, mergeFunction: (V, V) -> V): HashMap<K, V> {
val result = map1 // Step 1
for ((key, value) in map2) { // Step 2
result[key] = result[key]?.let { existingValue ->
mergeFunction(existingValue, value) // Step 3 (merge logic)
} ?: value
}
return result // Step 4
}
private fun addArray(array1: Array<Int>, array2: Array<Int>): Array<Int> {
return array1.zip(array2) { a, b -> a + b }.toTypedArray()
}
} }
} }

@ -20,6 +20,7 @@
package org.isoron.uhabits.core.ui.screens.habits.show.views package org.isoron.uhabits.core.ui.screens.habits.show.views
import org.isoron.uhabits.core.models.Habit import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.HabitGroup
data class NotesCardState( data class NotesCardState(
val description: String val description: String
@ -30,5 +31,9 @@ class NotesCardPresenter {
fun buildState(habit: Habit) = NotesCardState( fun buildState(habit: Habit) = NotesCardState(
description = habit.description description = habit.description
) )
fun buildState(habitGroup: HabitGroup) = NotesCardState(
description = habitGroup.description
)
} }
} }

@ -21,6 +21,7 @@ package org.isoron.uhabits.core.ui.screens.habits.show.views
import org.isoron.uhabits.core.models.Entry import org.isoron.uhabits.core.models.Entry
import org.isoron.uhabits.core.models.Habit 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.models.PaletteColor
import org.isoron.uhabits.core.ui.views.Theme import org.isoron.uhabits.core.ui.views.Theme
import org.isoron.uhabits.core.utils.DateUtils import org.isoron.uhabits.core.utils.DateUtils
@ -57,5 +58,27 @@ class OverviewCardPresenter {
theme = theme theme = theme
) )
} }
fun buildState(habitGroup: HabitGroup, theme: Theme): OverviewCardState {
val today = DateUtils.getTodayWithOffset()
val lastMonth = today.minus(30)
val lastYear = today.minus(365)
val scores = habitGroup.scores
val scoreToday = scores[today].value.toFloat()
val scoreLastMonth = scores[lastMonth].value.toFloat()
val scoreLastYear = scores[lastYear].value.toFloat()
val totalCount = habitGroup.habitList.sumOf { habit ->
habit.originalEntries.getKnown().count { it.value == Entry.YES_MANUAL }
.toLong()
}
return OverviewCardState(
color = habitGroup.color,
scoreToday = scoreToday,
scoreMonthDiff = scoreToday - scoreLastMonth,
scoreYearDiff = scoreToday - scoreLastYear,
totalCount = totalCount,
theme = theme
)
}
} }
} }

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save