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.nonTransitiveRClass=false
android.nonFinalResIds=false
org.gradle.java.installations.auto-download=true

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

@ -42,6 +42,22 @@
android:value=".activities.habits.list.ListHabitsActivity" />
</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
android:name="com.google.android.backup.api_key"
android:value="AEdPqrEAAAAI6aeWncbnMNo8E5GWeZ44dlc5cQ7tCROwFhOtiw" />
@ -72,6 +88,14 @@
android:value=".activities.habits.list.ListHabitsActivity" />
</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
android:name=".activities.settings.SettingsActivity"
android:label="@string/settings">
@ -112,6 +136,15 @@
</intent-filter>
</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
android:name=".activities.about.AboutActivity"
android:label="@string/about">

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

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

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

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

@ -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 timestamp = intent.extras?.getLong("timestamp")
if (habitId != null && timestamp != null) {
val habit = appComponent.habitList.getById(habitId)!!
val habit = appComponent.habitList.getById(habitId) ?: appComponent.habitGroupList.getHabitByID(habitId)!!
component.listHabitsBehavior.onEdit(habit, Timestamp(timestamp))
}
}
intent.getLongExtra("CLEAR_NOTIFICATION_HABIT_ID", -1).takeIf { it != -1L }?.let { id ->
val dismissHabit = appComponent.habitList.getById(id) ?: appComponent.habitGroupList.getHabitByID(id)
if (dismissHabit != null) {
appComponent.reminderController.onDismiss(dismissHabit)
} else {
val dismissHabitGroup = appComponent.habitGroupList.getById(id)!!
appComponent.reminderController.onDismiss(dismissHabitGroup)
}
}
intent = null
}

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

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

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

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

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

@ -36,6 +36,7 @@ import dagger.Lazy
import org.isoron.uhabits.R
import org.isoron.uhabits.activities.common.views.BundleSavedState
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.HabitGroup
import org.isoron.uhabits.inject.ActivityContext
import javax.inject.Inject
@ -62,8 +63,13 @@ class HabitCardListView(
set(value) {
field = value
attachedHolders
.map { it.itemView as HabitCardView }
.forEach { it.dataOffset = value }
.forEach {
if (it.itemView is HabitCardView) {
(it.itemView as HabitCardView).dataOffset = value
} else {
(it.itemView as HabitGroupCardView).dataOffset = value
}
}
}
private val attachedHolders = mutableListOf<HabitCardViewHolder>()
@ -79,7 +85,11 @@ class HabitCardListView(
}
fun createHabitCardView(): HabitCardView {
return cardViewFactory.create()
return cardViewFactory.createHabitCard()
}
fun createHabitGroupCardView(): HabitGroupCardView {
return cardViewFactory.createHabitGroupCard()
}
fun bindCardView(
@ -110,8 +120,28 @@ class HabitCardListView(
return cardView
}
fun bindGroupCardView(
holder: HabitCardViewHolder,
habitGroup: HabitGroup,
score: Double,
selected: Boolean
): View {
val cardView = holder.itemView as HabitGroupCardView
cardView.habitGroup = habitGroup
cardView.isSelected = selected
cardView.score = score
val detector = GestureDetector(context, CardViewGestureDetector(holder))
cardView.setOnTouchListener { _, ev ->
detector.onTouchEvent(ev)
true
}
return cardView
}
fun attachCardView(holder: HabitCardViewHolder) {
(holder.itemView as HabitCardView).dataOffset = dataOffset
(holder.itemView as? HabitCardView)?.dataOffset = dataOffset
attachedHolders.add(holder)
}

@ -55,7 +55,8 @@ class HabitCardViewFactory
private val numberPanelFactory: NumberPanelViewFactory,
private val behavior: ListHabitsBehavior
) {
fun create() = HabitCardView(context, checkmarkPanelFactory, numberPanelFactory, behavior)
fun createHabitCard() = HabitCardView(context, checkmarkPanelFactory, numberPanelFactory, behavior)
fun createHabitGroupCard() = HabitGroupCardView(context, behavior)
}
class HabitCardView(
@ -265,6 +266,15 @@ class HabitCardView(
}
scoreRing.apply {
setColor(c)
// if (h.isSubHabit()) {
val rightMargin = dp(8f).toInt()
val ringSize = dp(15f).toInt()
val leftMargin = if (h.isSubHabit() == true) dp(30f).toInt() else dp(8f).toInt()
layoutParams = LinearLayout.LayoutParams(ringSize, ringSize).apply {
setMargins(leftMargin, 0, rightMargin, 0)
gravity = Gravity.CENTER
}
// }
}
checkmarkPanel.apply {
color = c

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

@ -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
import android.content.ContentUris
import android.os.Bundle
import android.view.HapticFeedbackConstants
import android.view.Menu
@ -75,8 +74,15 @@ class ShowHabitActivity : AppCompatActivity(), CommandRunner.Listener {
super.onCreate(savedInstanceState)
val appComponent = (applicationContext as HabitsApplication).component
val habitList = appComponent.habitList
habit = habitList.getById(ContentUris.parseId(intent.data!!))!!
val habitGroupList = appComponent.habitGroupList
val groupId = intent.getLongExtra("groupId", -1L)
val habitList = if (groupId > 0) {
habitGroupList.getById(groupId)!!.habitList
} else {
appComponent.habitList
}
val id = intent.getLongExtra("habitId", -1L)
habit = habitList.getById(id)!!
preferences = appComponent.preferences
commandRunner = appComponent.commandRunner
widgetUpdater = appComponent.widgetUpdater

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

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

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

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

@ -29,6 +29,7 @@ import android.os.Build
import android.util.Log
import org.isoron.uhabits.core.AppScope
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.HabitGroup
import org.isoron.uhabits.core.reminders.ReminderScheduler.SchedulerResult
import org.isoron.uhabits.core.reminders.ReminderScheduler.SystemScheduler
import org.isoron.uhabits.core.utils.DateFormats
@ -75,6 +76,16 @@ class IntentScheduler
return schedule(reminderTime, intent, RTC_WAKEUP)
}
override fun scheduleShowReminder(
reminderTime: Long,
habitGroup: HabitGroup,
timestamp: Long
): SchedulerResult {
val intent = pendingIntents.showReminder(habitGroup, reminderTime, timestamp)
logReminderScheduled(habitGroup, reminderTime)
return schedule(reminderTime, intent, RTC_WAKEUP)
}
override fun scheduleWidgetUpdate(updateTime: Long): SchedulerResult {
val intent = pendingIntents.updateWidgets()
return schedule(updateTime, intent, RTC)
@ -94,4 +105,15 @@ class IntentScheduler
String.format("Setting alarm (%s): %s", time, name)
)
}
private fun logReminderScheduled(habitGroup: HabitGroup, reminderTime: Long) {
val min = min(5, habitGroup.name.length)
val name = habitGroup.name.substring(0, min)
val df = DateFormats.getBackupDateFormat()
val time = df.format(Date(reminderTime))
Log.i(
"ReminderHelper",
String.format("Setting alarm (%s): %s", time, name)
)
}
}

@ -31,8 +31,10 @@ import android.net.Uri
import android.os.Build
import org.isoron.uhabits.activities.habits.list.ListHabitsActivity
import org.isoron.uhabits.activities.habits.show.ShowHabitActivity
import org.isoron.uhabits.activities.habits.show.ShowHabitGroupActivity
import org.isoron.uhabits.core.AppScope
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.HabitGroup
import org.isoron.uhabits.core.models.Timestamp
import org.isoron.uhabits.inject.AppContext
import org.isoron.uhabits.receivers.ReminderReceiver
@ -69,6 +71,17 @@ class PendingIntentFactory
FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT
)
fun dismissNotification(habitGroup: HabitGroup): PendingIntent =
getBroadcast(
context,
0,
Intent(context, ReminderReceiver::class.java).apply {
action = WidgetReceiver.ACTION_DISMISS_REMINDER
data = Uri.parse(habitGroup.uriString)
},
FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT
)
fun removeRepetition(habit: Habit, timestamp: Timestamp?): PendingIntent =
getBroadcast(
context,
@ -92,6 +105,17 @@ class PendingIntentFactory
)
.getPendingIntent(0, FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT)!!
fun showHabitGroup(habitGroup: HabitGroup): PendingIntent =
androidx.core.app.TaskStackBuilder
.create(context)
.addNextIntentWithParentStack(
intentFactory.startShowHabitGroupActivity(
context,
habitGroup
)
)
.getPendingIntent(0, FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT)!!
fun showHabitTemplate(): PendingIntent {
return getActivity(
context,
@ -101,6 +125,15 @@ class PendingIntentFactory
)
}
fun showHabitGroupTemplate(): PendingIntent {
return getActivity(
context,
0,
Intent(context, ShowHabitGroupActivity::class.java),
getIntentTemplateFlags()
)
}
fun showHabitFillIn(habit: Habit) =
Intent().apply {
data = Uri.parse(habit.uriString)
@ -123,6 +156,23 @@ class PendingIntentFactory
FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT
)
fun showReminder(
habitGroup: HabitGroup,
reminderTime: Long?,
timestamp: Long
): PendingIntent =
getBroadcast(
context,
(habitGroup.id!! % Integer.MAX_VALUE).toInt() + 1,
Intent(context, ReminderReceiver::class.java).apply {
action = ReminderReceiver.ACTION_SHOW_REMINDER
data = Uri.parse(habitGroup.uriString)
putExtra("timestamp", timestamp)
putExtra("reminderTime", reminderTime)
},
FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT
)
fun snoozeNotification(habit: Habit): PendingIntent =
getBroadcast(
context,
@ -134,6 +184,17 @@ class PendingIntentFactory
FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT
)
fun snoozeNotification(habitGroup: HabitGroup): PendingIntent =
getBroadcast(
context,
0,
Intent(context, ReminderReceiver::class.java).apply {
data = Uri.parse(habitGroup.uriString)
action = ReminderReceiver.ACTION_SNOOZE_REMINDER
},
FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT
)
fun toggleCheckmark(habit: Habit, timestamp: Long?): PendingIntent =
getBroadcast(
context,
@ -185,6 +246,26 @@ class PendingIntentFactory
putExtra("timestamp", timestamp.unixTime)
}
fun showHabitList(): PendingIntent {
return getActivity(
context,
1,
Intent(context, ListHabitsActivity::class.java),
getIntentTemplateFlags()
)
}
fun showHabitListWithNotificationClear(id: Long): PendingIntent {
return getActivity(
context,
0,
Intent(context, ListHabitsActivity::class.java).apply {
putExtra("CLEAR_NOTIFICATION_HABIT_ID", id)
},
getIntentTemplateFlags()
)
}
private fun getIntentTemplateFlags(): Int {
var flags = 0
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {

@ -35,6 +35,7 @@ import androidx.core.app.NotificationManagerCompat
import org.isoron.uhabits.R
import org.isoron.uhabits.core.AppScope
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.HabitGroup
import org.isoron.uhabits.core.models.Timestamp
import org.isoron.uhabits.core.preferences.Preferences
import org.isoron.uhabits.core.ui.NotificationTray
@ -90,6 +91,34 @@ class AndroidNotificationTray
active.add(notificationId)
}
override fun showNotification(
habitGroup: HabitGroup,
notificationId: Int,
timestamp: Timestamp,
reminderTime: Long
) {
val notificationManager = NotificationManagerCompat.from(context)
val notification = buildNotification(habitGroup, reminderTime, timestamp)
createAndroidNotificationChannel(context)
try {
notificationManager.notify(notificationId, notification)
} catch (e: RuntimeException) {
// Some Xiaomi phones produce a RuntimeException if custom notification sounds are used.
Log.i(
"AndroidNotificationTray",
"Failed to show notification. Retrying without sound."
)
val n = buildNotification(
habitGroup,
reminderTime,
timestamp,
disableSound = true
)
notificationManager.notify(notificationId, n)
}
active.add(notificationId)
}
fun buildNotification(
habit: Habit,
reminderTime: Long,
@ -163,6 +192,58 @@ class AndroidNotificationTray
return builder.build()
}
fun buildNotification(
habitGroup: HabitGroup,
reminderTime: Long,
timestamp: Timestamp,
disableSound: Boolean = false
): Notification {
val enterAction = Action(
R.drawable.ic_action_check,
context.getString(R.string.enter),
pendingIntents.showHabitListWithNotificationClear(habitGroup.id!!)
)
val wearableBg = decodeResource(context.resources, R.drawable.stripe)
// Even though the set of actions is the same on the phone and
// on the watch, Pebble requires us to add them to the
// WearableExtender.
val wearableExtender = WearableExtender().setBackground(wearableBg)
val defaultText = context.getString(R.string.default_reminder_question)
val builder = Builder(context, REMINDERS_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(habitGroup.name)
.setContentText(if (habitGroup.question.isBlank()) defaultText else habitGroup.question)
.setContentIntent(pendingIntents.showHabitGroup(habitGroup))
.setDeleteIntent(pendingIntents.dismissNotification(habitGroup))
.setSound(null)
.setWhen(reminderTime)
.setShowWhen(true)
.setOngoing(preferences.shouldMakeNotificationsSticky())
wearableExtender.addAction(enterAction)
builder.addAction(enterAction)
if (!disableSound) {
builder.setSound(ringtoneManager.getURI())
}
if (SDK_INT < Build.VERSION_CODES.S) {
val snoozeAction = Action(
R.drawable.ic_action_snooze,
context.getString(R.string.snooze),
pendingIntents.snoozeNotification(habitGroup)
)
wearableExtender.addAction(snoozeAction)
builder.addAction(snoozeAction)
}
builder.extend(wearableExtender)
return builder.build()
}
companion object {
private const val REMINDERS_CHANNEL_ID = "REMINDERS"
fun createAndroidNotificationChannel(context: Context) {

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

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

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

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

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

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

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

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

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

@ -24,7 +24,6 @@ import org.isoron.uhabits.R
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.Timestamp
import org.isoron.uhabits.intents.PendingIntentFactory
import java.lang.IllegalStateException
enum class StackWidgetType(val value: Int) {
CHECKMARK(0), FREQUENCY(1), SCORE(2), // habit strength widget
@ -95,6 +94,17 @@ enum class StackWidgetType(val value: Int) {
}
}
fun getPendingIntentTemplate(
factory: PendingIntentFactory,
widgetType: StackWidgetType,
isHabitGroups: Boolean
): PendingIntent {
return when (widgetType) {
CHECKMARK, HISTORY, STREAKS, TARGET -> throw RuntimeException()
FREQUENCY, SCORE -> factory.showHabitGroupTemplate()
}
}
fun getIntentFillIn(
factory: PendingIntentFactory,
widgetType: StackWidgetType,

@ -25,7 +25,6 @@ import android.appwidget.AppWidgetManager.INVALID_APPWIDGET_ID
import android.content.Intent
import android.os.Bundle
import android.widget.ArrayAdapter
import android.widget.Button
import android.widget.ListView
import android.widget.TextView
import org.isoron.uhabits.HabitsApplication
@ -44,6 +43,10 @@ class NumericalHabitPickerDialog : HabitPickerDialog() {
override fun getEmptyMessage() = R.string.no_numerical_habits
}
class HabitAndGroupPickerDialog : HabitPickerDialog() {
override fun shouldShowGroups(): Boolean = true
}
open class HabitPickerDialog : Activity() {
private var widgetId = 0
@ -52,6 +55,8 @@ open class HabitPickerDialog : Activity() {
protected open fun shouldHideNumerical() = false
protected open fun shouldHideBoolean() = false
protected open fun shouldShowGroups() = false
protected open fun getEmptyMessage() = R.string.no_habits
override fun onCreate(savedInstanceState: Bundle?) {
@ -59,6 +64,7 @@ open class HabitPickerDialog : Activity() {
val component = (applicationContext as HabitsApplication).component
AndroidThemeSwitcher(this, component.preferences).apply()
val habitList = component.habitList
val habitGroupList = component.habitGroupList
widgetPreferences = component.widgetPreferences
widgetUpdater = component.widgetUpdater
widgetId = intent.extras?.getInt(EXTRA_APPWIDGET_ID, INVALID_APPWIDGET_ID) ?: 0
@ -73,6 +79,21 @@ open class HabitPickerDialog : Activity() {
habitNames.add(h.name)
}
for (hgr in habitGroupList) {
if (hgr.isArchived) continue
if (shouldShowGroups()) {
habitIds.add(hgr.id!!)
habitNames.add(hgr.name)
}
for (h in hgr.habitList) {
if (h.isArchived) continue
if (!h.isNumerical and shouldHideBoolean()) continue
habitIds.add(h.id!!)
habitNames.add(h.name)
}
}
if (habitNames.isEmpty()) {
setContentView(R.layout.widget_empty_activity)
findViewById<TextView>(R.id.message).setText(getEmptyMessage())
@ -81,7 +102,6 @@ open class HabitPickerDialog : Activity() {
setContentView(R.layout.widget_configure_activity)
val listView = findViewById<ListView>(R.id.listView)
val saveButton = findViewById<Button>(R.id.buttonSave)
with(listView) {
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" />
</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-->
<!-- android:layout_width="match_parent"-->
<!-- 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:textColor="?attr/contrast60"
android:layout_marginStart="4dp"
android:layout_marginEnd="16dp"
android:layout_marginEnd="8dp"
android:textSize="@dimen/smallTextSize" />
<TextView
@ -92,10 +92,31 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingTop="1dp"
android:textColor="?attr/contrast60"
android:text="8:00 AM"
android:layout_marginStart="4dp"
android:textSize="@dimen/smallTextSize" />
</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>

@ -41,6 +41,16 @@
android:title="@string/unarchive"
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
android:id="@+id/action_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_check">&#xf00c;</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_bell_o">&#xf0f3;</string>
<string translatable="false" name="fa_calendar">&#xf073;</string>

@ -29,6 +29,8 @@
<string name="delete">Delete</string>
<string name="archive">Archive</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="color_picker_default_title">Change color</string>
<string name="toast_habit_created">Habit created</string>
@ -62,6 +64,8 @@
<string name="reminder_off">Off</string>
<string name="create_habit">Create 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="snooze">Later</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="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="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_month">%d times per month</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="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="no_sub_habits">Group contains no habits.</string>
<string name="no_habit_groups">No habit groups</string>
</resources>

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

@ -27,7 +27,7 @@
android:previewImage="@drawable/widget_preview_score"
android:resizeMode="vertical|horizontal"
android:updatePeriodMillis="3600000"
android:configure="org.isoron.uhabits.widgets.activities.HabitPickerDialog"
android:configure="org.isoron.uhabits.widgets.activities.HabitAndGroupPickerDialog"
android:widgetCategory="home_screen">
</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_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
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.HabitGroup
import org.isoron.uhabits.core.models.HabitList
data class DeleteHabitsCommand(
@ -26,6 +27,13 @@ data class DeleteHabitsCommand(
val selected: List<Habit>
) : Command {
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.SQLException
import java.sql.Types
import java.util.ArrayList
class JdbcDatabase(private val connection: Connection) : Database {
private var transactionSuccessful = false
@ -51,6 +50,14 @@ class JdbcDatabase(private val connection: Connection) : Database {
valuesStr.add(value.toString())
}
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(
"update %s set %s where %s",
tableName,

@ -22,8 +22,6 @@ import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.tuple.ImmutablePair
import org.apache.commons.lang3.tuple.Pair
import java.lang.reflect.Field
import java.util.ArrayList
import java.util.HashMap
import java.util.LinkedList
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> {
val records: MutableList<T> = LinkedList()
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.commands.CommandRunner
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.EditHabitGroupCommand
import org.isoron.uhabits.core.database.DatabaseOpener
import org.isoron.uhabits.core.database.MigrationHelper
import org.isoron.uhabits.core.database.Repository
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.ModelFactory
import org.isoron.uhabits.core.models.Timestamp
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.utils.isSQLite3File
import java.io.File
@ -42,6 +46,7 @@ import javax.inject.Inject
class LoopDBImporter
@Inject constructor(
@AppScope val habitList: HabitList,
@AppScope val habitGroupList: HabitGroupList,
@AppScope val modelFactory: ModelFactory,
@AppScope val opener: DatabaseOpener,
@AppScope val runner: CommandRunner,
@ -74,26 +79,66 @@ class LoopDBImporter
helper.migrateTo(DATABASE_VERSION)
val habitsRepository = Repository(HabitRecord::class.java, db)
val habitGroupsRepository = Repository(HabitGroupRecord::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")) {
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())
if (habit == null) {
habit = modelFactory.buildHabit()
habitRecord.id = null
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 {
val modified = modelFactory.buildHabit()
habitRecord.id = habit.id
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
habit = habitList.getByUUID(habitRecord.uuid)!!
habit = habitList.getByUUID(habitRecord.uuid) ?: habitGroupList.getHabitByUUID(habitRecord.uuid)!!
val entries = habit.originalEntries
// Import entries
@ -106,6 +151,9 @@ class LoopDBImporter
}
habit.recompute()
}
habitGroupList.forEach { it.recompute() }
habitGroupList.resort()
habitList.resort()
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_MANUAL
import org.isoron.uhabits.core.utils.DateUtils
import java.util.ArrayList
import java.util.Calendar
import javax.annotation.concurrent.ThreadSafe
import kotlin.collections.set
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
@ThreadSafe
open class EntryList {
@ -151,6 +151,22 @@ open class EntryList {
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) {
val length: Int
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.
*/

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

@ -29,6 +29,15 @@ data class HabitMatcher(
if (isReminderRequired && !habit.hasReminder()) return false
if (!isCompletedAllowed && habit.isCompletedToday()) 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
}

@ -20,6 +20,7 @@ package org.isoron.uhabits.core.models
import org.isoron.uhabits.core.database.Repository
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
/**
@ -38,11 +39,23 @@ interface ModelFactory {
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 buildOriginalEntries(): EntryList
fun buildHabitList(): HabitList
fun buildHabitGroupList(): HabitGroupList
fun buildScoreList(): ScoreList
fun buildStreakList(): StreakList
fun buildHabitListRepository(): Repository<HabitRecord>
fun buildRepetitionListRepository(): Repository<EntryRecord>
fun buildHabitGroupListRepository(): Repository<HabitGroupRecord>
}

@ -19,8 +19,6 @@
package org.isoron.uhabits.core.models
import org.isoron.uhabits.core.models.Score.Companion.compute
import java.util.ArrayList
import java.util.HashMap
import javax.annotation.concurrent.ThreadSafe
import kotlin.math.max
import kotlin.math.min
@ -138,4 +136,21 @@ class ScoreList {
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
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))
}
@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.HabitMatcher
import org.isoron.uhabits.core.utils.DateUtils.Companion.getTodayWithOffset
import java.util.ArrayList
import java.util.Comparator
import java.util.LinkedList
import java.util.Objects
@ -53,6 +51,13 @@ class MemoryHabitList : HabitList {
getComposedComparatorByOrder(primaryOrder, secondaryOrder)
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(
matcher: HabitMatcher,
@ -61,6 +66,7 @@ class MemoryHabitList : HabitList {
) : super(matcher) {
this.parent = parent
this.comparator = comparator
this.groupId = parent.groupId
primaryOrder = parent.primaryOrder
secondaryOrder = parent.secondaryOrder
parent.observable.addListener { loadFromParent() }
@ -79,6 +85,17 @@ class MemoryHabitList : HabitList {
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
override fun getById(id: Long): Habit? {
for (h in list) {
@ -182,6 +199,13 @@ class MemoryHabitList : HabitList {
observable.notifyListeners()
}
@Synchronized
override fun removeAt(position: Int) {
throwIfHasParent()
list.removeAt(position)
observable.notifyListeners()
}
@Synchronized
override fun reorder(from: Habit, to: Habit) {
throwIfHasParent()
@ -218,6 +242,8 @@ class MemoryHabitList : HabitList {
checkNotNull(parent)
list.clear()
for (h in parent!!) if (filter.matches(h)) list.add(h)
primaryOrder = parent!!.primaryOrder
secondaryOrder = parent!!.secondaryOrder
resort()
}

@ -27,8 +27,10 @@ class MemoryModelFactory : ModelFactory {
override fun buildComputedEntries() = EntryList()
override fun buildOriginalEntries() = EntryList()
override fun buildHabitList() = MemoryHabitList()
override fun buildHabitGroupList() = MemoryHabitGroupList()
override fun buildScoreList() = ScoreList()
override fun buildStreakList() = StreakList()
override fun buildHabitListRepository() = 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.StreakList
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 javax.inject.Inject
@ -38,6 +39,8 @@ class SQLModelFactory
override fun buildOriginalEntries() = SQLiteEntryList(database)
override fun buildComputedEntries() = EntryList()
override fun buildHabitList() = SQLiteHabitList(this)
override fun buildHabitGroupList() = SQLiteHabitGroupList(this)
override fun buildScoreList() = ScoreList()
override fun buildStreakList() = StreakList()
@ -46,4 +49,7 @@ class SQLModelFactory
override fun buildRepetitionListRepository() =
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) {
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() {
if (loaded) return
loaded = true
list.groupId = this.groupId
list.removeAll()
val records = repository.findAll("order by position")
val records = repository.findAll("order by group_id, position")
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
val h = modelFactory.buildHabit()
rec.copyTo(h)
(h.originalEntries as SQLiteEntryList).habitId = h.id
list.add(h)
if (h.groupId == list.groupId) list.add(h)
expectedPosition++
}
if (shouldRebuildOrder) rebuildOrder()
}
@ -54,15 +62,48 @@ class SQLiteHabitList @Inject constructor(private val modelFactory: ModelFactory
override fun add(habit: Habit) {
loadRecords()
habit.position = size()
habit.id = repository.getNextAvailableId("habitandgroup")
val record = HabitRecord()
record.copyFrom(habit)
repository.save(record)
habit.id = record.id
(habit.originalEntries as SQLiteEntryList).habitId = record.id
list.add(habit)
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
override fun getById(id: Long): Habit? {
loadRecords()
@ -103,6 +144,12 @@ class SQLiteHabitList @Inject constructor(private val modelFactory: ModelFactory
observable.notifyListeners()
}
override var collapsed: Boolean = list.collapsed
set(value) {
field = value
list.collapsed = value
}
@Synchronized
override fun indexOf(h: Habit): Int {
loadRecords()
@ -115,19 +162,6 @@ class SQLiteHabitList @Inject constructor(private val modelFactory: ModelFactory
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: Habit) {
loadRecords()
@ -143,6 +177,12 @@ class SQLiteHabitList @Inject constructor(private val modelFactory: ModelFactory
observable.notifyListeners()
}
@Synchronized
override fun removeAt(position: Int) {
loadRecords()
list.removeAt(position)
}
@Synchronized
override fun 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
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) {
id = model.id
name = model.name
@ -102,6 +108,8 @@ class HabitRecord {
position = model.position
question = model.question
uuid = model.uuid
groupId = model.groupId
groupUUID = model.groupUUID
val (numerator, denominator) = model.frequency
freqNum = numerator
freqDen = denominator
@ -130,6 +138,8 @@ class HabitRecord {
habit.unit = unit!!
habit.position = position!!
habit.uuid = uuid
habit.groupId = groupId
habit.groupUUID = groupUUID
if (reminderHour != null && reminderMin != null) {
habit.reminder = Reminder(
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.CreateRepetitionCommand
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.HabitMatcher
import org.isoron.uhabits.core.preferences.WidgetPreferences
@ -39,6 +41,7 @@ import javax.inject.Inject
class ReminderScheduler @Inject constructor(
private val commandRunner: CommandRunner,
private val habitList: HabitList,
private val habitGroupList: HabitGroupList,
private val sys: SystemScheduler,
private val widgetPreferences: WidgetPreferences
) : CommandRunner.Listener {
@ -83,6 +86,40 @@ class ReminderScheduler @Inject constructor(
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
fun scheduleAtTime(habit: Habit, reminderTime: Long) {
sys.log("ReminderScheduler", "Scheduling alarm for habit=" + habit.id)
@ -108,16 +145,48 @@ class ReminderScheduler @Inject constructor(
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
fun scheduleAll() {
sys.log("ReminderScheduler", "Scheduling all alarms")
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 reminderSubHabits) schedule(habit)
for (hgr in reminderHabitGroups) schedule(hgr)
}
@Synchronized
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
@ -138,6 +207,14 @@ class ReminderScheduler @Inject constructor(
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 {
fun scheduleShowReminder(
reminderTime: Long,
@ -145,6 +222,12 @@ class ReminderScheduler @Inject constructor(
timestamp: Long
): SchedulerResult
fun scheduleShowReminder(
reminderTime: Long,
habitGroup: HabitGroup,
timestamp: Long
): SchedulerResult
fun scheduleWidgetUpdate(updateTime: Long): SchedulerResult?
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.CommandRunner
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.models.Habit
import org.isoron.uhabits.core.models.HabitGroup
import org.isoron.uhabits.core.models.Timestamp
import org.isoron.uhabits.core.preferences.Preferences
import org.isoron.uhabits.core.tasks.Task
import org.isoron.uhabits.core.tasks.TaskRunner
import java.util.HashMap
import java.util.Locale
import java.util.Objects
import javax.inject.Inject
@ -40,11 +41,18 @@ class NotificationTray @Inject constructor(
private val preferences: Preferences,
private val systemTray: SystemTray
) : 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) {
val notificationId = getNotificationId(habit)
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) {
@ -56,6 +64,13 @@ class NotificationTray @Inject constructor(
val (_, deleted) = command
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() {
@ -64,10 +79,16 @@ class NotificationTray @Inject constructor(
fun show(habit: Habit, timestamp: Timestamp, reminderTime: Long) {
val data = NotificationData(timestamp, reminderTime)
active[habit] = data
activeHabits[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() {
commandRunner.addListener(this)
preferences.addListener(this)
@ -83,18 +104,32 @@ class NotificationTray @Inject constructor(
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() {
for ((habit, data) in active.entries) {
for ((habit, data) in activeHabits.entries) {
taskRunner.execute(ShowNotificationTask(habit, data))
}
for ((habitGroup, data) in activeHabitGroups.entries) {
taskRunner.execute(ShowNotificationTask(habitGroup, data))
}
}
fun reshow(habit: Habit) {
active[habit]?.let {
activeHabits[habit]?.let {
taskRunner.execute(ShowNotificationTask(habit, it))
}
}
fun reshow(habitGroup: HabitGroup) {
activeHabitGroups[habitGroup]?.let {
taskRunner.execute(ShowNotificationTask(habitGroup, it))
}
}
interface SystemTray {
fun removeNotification(notificationId: Int)
fun showNotification(
@ -104,48 +139,79 @@ class NotificationTray @Inject constructor(
reminderTime: Long
)
fun showNotification(
habitGroup: HabitGroup,
notificationId: Int,
timestamp: Timestamp,
reminderTime: Long
)
fun log(msg: String)
}
internal class NotificationData(val timestamp: Timestamp, val reminderTime: Long)
private inner class ShowNotificationTask(private val habit: Habit, data: NotificationData) :
Task {
private inner class ShowNotificationTask private constructor(
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
private val timestamp: Timestamp = data.timestamp
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() {
isCompleted = habit.isCompletedToday()
isCompleted = habit?.isCompletedToday() ?: habitGroup!!.isCompletedToday()
}
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) {
systemTray.log(
String.format(
Locale.US,
"Habit %d already checked. Skipping.",
habit.id
"%s %d already checked. Skipping.",
type,
id
)
)
return
}
if (!habit.hasReminder()) {
if (!hasReminder) {
systemTray.log(
String.format(
Locale.US,
"Habit %d does not have a reminder. Skipping.",
habit.id
"%s %d does not have a reminder. Skipping.",
type,
id
)
)
return
}
if (habit.isArchived) {
if (isArchived) {
systemTray.log(
String.format(
Locale.US,
"Habit %d is archived. Skipping.",
habit.id
"%s %d is archived. Skipping.",
type,
id
)
)
return
@ -154,23 +220,33 @@ class NotificationTray @Inject constructor(
systemTray.log(
String.format(
Locale.US,
"Habit %d not supposed to run today. Skipping.",
habit.id
"%s %d not supposed to run today. Skipping.",
type,
id
)
)
return
}
systemTray.showNotification(
habit,
getNotificationId(habit),
timestamp,
reminderTime
)
if (habit != null) {
systemTray.showNotification(
habit,
getNotificationId(habit),
timestamp,
reminderTime
)
} else {
systemTray.showNotification(
habitGroup!!,
getNotificationId(habitGroup),
timestamp,
reminderTime
)
}
}
private fun shouldShowReminderToday(): Boolean {
if (!habit.hasReminder()) return false
val reminder = habit.reminder
if (!hasReminder) return false
val reminder = habit?.reminder ?: habitGroup!!.reminder
val reminderDays = Objects.requireNonNull(reminder)!!.days.toArray()
val weekday = timestamp.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.io.Logging
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.Order
import org.isoron.uhabits.core.models.HabitMatcher
import org.isoron.uhabits.core.tasks.Task
import org.isoron.uhabits.core.tasks.TaskRunner
import org.isoron.uhabits.core.utils.DateUtils.Companion.getTodayWithOffset
import java.util.ArrayList
import java.util.Arrays
import java.util.HashMap
import java.util.LinkedList
import java.util.TreeSet
import javax.inject.Inject
@ -53,7 +53,8 @@ import javax.inject.Inject
*/
@AppScope
class HabitCardListCache @Inject constructor(
private val allHabits: HabitList,
private val habits: HabitList,
private val habitGroups: HabitGroupList,
private val commandRunner: CommandRunner,
taskRunner: TaskRunner,
logging: Logging
@ -66,6 +67,7 @@ class HabitCardListCache @Inject constructor(
private var listener: Listener
private val data: CacheData
private var filteredHabits: HabitList
private var filteredHabitGroups: HabitGroupList
private val taskRunner: TaskRunner
@Synchronized
@ -74,42 +76,86 @@ class HabitCardListCache @Inject constructor(
}
@Synchronized
fun getCheckmarks(habitId: Long): IntArray {
return data.checkmarks[habitId]!!
fun getCheckmarks(habitID: Long): IntArray {
return data.checkmarks[habitID]!!
}
@Synchronized
fun getNotes(habitId: Long): Array<String> {
return data.notes[habitId]!!
fun getNotes(habitID: Long): Array<String> {
return data.notes[habitID]!!
}
@Synchronized
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.
*
* @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
*/
@Synchronized
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
val habitCount: Int
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
@set:Synchronized
var primaryOrder: Order
get() = filteredHabits.primaryOrder
set(order) {
allHabits.primaryOrder = order
habits.primaryOrder = order
habitGroups.primaryOrder = order
filteredHabits.primaryOrder = order
filteredHabitGroups.primaryOrder = order
refreshAllHabits()
}
@ -118,14 +164,16 @@ class HabitCardListCache @Inject constructor(
var secondaryOrder: Order
get() = filteredHabits.secondaryOrder
set(order) {
allHabits.secondaryOrder = order
habits.secondaryOrder = order
habitGroups.secondaryOrder = order
filteredHabits.secondaryOrder = order
filteredHabitGroups.secondaryOrder = order
refreshAllHabits()
}
@Synchronized
fun getScore(habitId: Long): Double {
return data.scores[habitId]!!
fun getScore(id: Long): Double {
return data.scores[id]!!
}
@Synchronized
@ -163,22 +211,59 @@ class HabitCardListCache @Inject constructor(
@Synchronized
fun remove(id: Long) {
val h = data.idToHabit[id] ?: return
val position = data.habits.indexOf(h)
data.habits.removeAt(position)
data.idToHabit.remove(id)
data.checkmarks.remove(id)
data.notes.remove(id)
data.scores.remove(id)
listener.onItemRemoved(position)
val position = data.idToPosition[id] ?: return
val type = data.positionTypes[position]
if (type == STANDALONE_HABIT) {
val h = data.idToHabit[id]
if (h != null) {
data.habits.removeAt(position)
data.removeWithID(id)
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
fun reorder(from: Int, to: Int) {
val fromHabit = data.habits[from]
data.habits.removeAt(from)
data.habits.add(to, fromHabit)
listener.onItemMoved(from, to)
if (from == to) return
val type = data.positionTypes[from]
if (type == STANDALONE_HABIT || type == SUB_HABIT) {
val habit = data.positionToHabit[from]!!
data.performMove(habit, from, to)
} else {
val habitGroup = data.positionToHabitGroup[from]!!
data.performMove(habitGroup, from, to)
}
}
@Synchronized
@ -188,7 +273,8 @@ class HabitCardListCache @Inject constructor(
@Synchronized
fun setFilter(matcher: HabitMatcher) {
filteredHabits = allHabits.getFiltered(matcher)
filteredHabits = habits.getFiltered(matcher)
filteredHabitGroups = habitGroups.getFiltered(matcher)
}
@Synchronized
@ -210,7 +296,15 @@ class HabitCardListCache @Inject constructor(
private inner class CacheData {
val idToHabit: HashMap<Long?, Habit> = HashMap()
val idToHabitGroup: HashMap<Long?, HabitGroup> = HashMap()
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 scores: HashMap<Long?, Double>
val notes: HashMap<Long?, Array<String>>
@ -243,12 +337,20 @@ class HabitCardListCache @Inject constructor(
@Synchronized
fun copyScoresFrom(oldData: CacheData) {
for (id in idToHabit.keys) {
if (oldData.scores.containsKey(id)) {
scores[id] =
oldData.scores[id]!!
for (uuid in idToHabit.keys) {
if (oldData.scores.containsKey(uuid)) {
scores[uuid] =
oldData.scores[uuid]!!
} else {
scores[uuid] = 0.0
}
}
for (uuid in idToHabitGroup.keys) {
if (oldData.scores.containsKey(uuid)) {
scores[uuid] =
oldData.scores[uuid]!!
} else {
scores[id] = 0.0
scores[uuid] = 0.0
}
}
}
@ -256,17 +358,221 @@ class HabitCardListCache @Inject constructor(
@Synchronized
fun fetchHabits() {
for (h in filteredHabits) {
if (h.id == null) continue
if (h.uuid == null) continue
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
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.
*/
init {
habits = LinkedList()
habitGroups = LinkedList()
subHabits = LinkedList()
positionTypes = LinkedList()
positionIndices = LinkedList()
idToPosition = HashMap()
positionToHabit = HashMap()
positionToHabitGroup = HashMap()
checkmarks = HashMap()
scores = HashMap()
notes = HashMap()
@ -275,19 +581,19 @@ class HabitCardListCache @Inject constructor(
private inner class RefreshTask : Task {
private val newData: CacheData
private val targetId: Long?
private val targetID: Long?
private var isCancelled = false
private var runner: TaskRunner? = null
constructor() {
newData = CacheData()
targetId = null
targetID = null
isCancelled = false
}
constructor(targetId: Long) {
constructor(targetID: Long) {
newData = CacheData()
this.targetId = targetId
this.targetID = targetID
}
@Synchronized
@ -298,27 +604,35 @@ class HabitCardListCache @Inject constructor(
@Synchronized
override fun doInBackground() {
newData.fetchHabits()
newData.rebuildPositions()
newData.copyScoresFrom(data)
newData.copyCheckmarksFrom(data)
newData.copyNoteIndicatorsFrom(data)
val today = getTodayWithOffset()
val dateFrom = today.minus(checkmarkCount - 1)
if (runner != null) runner!!.publishProgress(this, -1)
for (position in newData.habits.indices) {
for ((position, type) in newData.positionTypes.withIndex()) {
if (isCancelled) return
val habit = newData.habits[position]
if (targetId != null && targetId != habit.id) continue
newData.scores[habit.id] = habit.scores[today].value
val list: MutableList<Int> = ArrayList()
val notes: MutableList<String> = ArrayList()
for ((_, value, note) in habit.computedEntries.getByInterval(dateFrom, today)) {
list.add(value)
notes.add(note)
if (type == STANDALONE_HABIT || type == SUB_HABIT) {
val habit = newData.positionToHabit[position]!!
if (targetID != null && targetID != habit.id) continue
newData.scores[habit.id] = habit.scores[today].value
val list: MutableList<Int> = ArrayList()
val notes: MutableList<String> = ArrayList()
for ((_, value, note) in habit.computedEntries.getByInterval(dateFrom, today)) {
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
private fun performInsert(habit: Habit, position: Int) {
if (!data.isValidInsert(habit, position)) return
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.scores[id] = newData.scores[id]!!
data.checkmarks[id] = newData.checkmarks[id]!!
@ -350,76 +679,119 @@ class HabitCardListCache @Inject constructor(
}
@Synchronized
private fun performMove(
habit: Habit,
fromPosition: Int,
toPosition: Int
) {
data.habits.removeAt(fromPosition)
// 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
private fun performInsert(habitGroup: HabitGroup, position: Int) {
if (!data.isValidInsert(habitGroup, position)) return
val id = habitGroup.id
val prevIdx = newData.positionIndices[position]
val habitList = newData.subHabits[prevIdx]
val idx = if (position < data.positionIndices.size) {
data.positionIndices[position]
} else {
toPosition
data.habitGroups.size
}
data.habits.add(checkedToPosition, habit)
listener.onItemMoved(fromPosition, checkedToPosition)
data.habitGroups.add(idx, habitGroup)
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
private fun performUpdate(id: Long, position: Int) {
var unchanged = true
val oldScore = data.scores[id]!!
val oldCheckmarks = data.checkmarks[id]
val oldNoteIndicators = data.notes[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 (!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
data.scores[id] = newScore
data.checkmarks[id] = newCheckmarks
data.notes[id] = newNoteIndicators
listener.onItemChanged(position)
}
@Synchronized
private fun processPosition(currentPosition: Int) {
val habit = newData.habits[currentPosition]
val id = habit.id
val prevPosition = data.habits.indexOf(habit)
if (prevPosition < 0) {
performInsert(habit, currentPosition)
} else {
if (prevPosition != currentPosition) {
performMove(
habit,
prevPosition,
currentPosition
)
val type = newData.positionTypes[currentPosition]
if (type == STANDALONE_HABIT || type == SUB_HABIT) {
val habit = newData.positionToHabit[currentPosition]!!
val id = habit.id ?: throw NullPointerException()
val prevPosition = data.idToPosition[id] ?: -1
val newPosition = if (type == STANDALONE_HABIT) {
currentPosition
} else {
val hgrPos = data.idToPosition[habit.groupId]!!
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
private fun processRemovedHabits() {
val before: Set<Long?> = data.idToHabit.keys
val after: Set<Long?> = newData.idToHabit.keys
val before: Set<Long?> = (data.idToHabit.keys).union(data.idToHabitGroup.keys)
val after: Set<Long?> = (newData.idToHabit.keys).union(newData.idToHabitGroup.keys)
val removed: MutableSet<Long?> = TreeSet(before)
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 {
filteredHabits = allHabits
filteredHabits = habits
filteredHabitGroups = habitGroups
this.taskRunner = taskRunner
listener = object : Listener {}
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.CreateRepetitionCommand
import org.isoron.uhabits.core.commands.RefreshParentGroupCommand
import org.isoron.uhabits.core.models.Entry.Companion.YES_MANUAL
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.HabitType
import org.isoron.uhabits.core.models.NumericalHabitType.AT_LEAST
@ -40,6 +43,7 @@ import kotlin.math.roundToInt
open class ListHabitsBehavior @Inject constructor(
private val habitList: HabitList,
private val habitGroupList: HabitGroupList,
private val dirFinder: DirFinder,
private val taskRunner: TaskRunner,
private val screen: Screen,
@ -51,7 +55,12 @@ open class ListHabitsBehavior @Inject constructor(
screen.showHabitScreen(h)
}
fun onClickHabitGroup(hgr: HabitGroup) {
screen.showHabitGroupScreen(hgr)
}
fun onEdit(habit: Habit, timestamp: Timestamp?) {
val list = if (habit.isSubHabit()) habit.group!!.habitList else habitList
val entry = habit.computedEntries.get(timestamp!!)
if (habit.type == HabitType.NUMERICAL) {
val oldValue = entry.value.toDouble() / 1000
@ -65,7 +74,8 @@ open class ListHabitsBehavior @Inject constructor(
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 {
screen.showCheckmarkPopup(
@ -74,7 +84,8 @@ open class ListHabitsBehavior @Inject constructor(
habit.color
) { newValue: Int, newNotes: String, x: Float, y: Float ->
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) {
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() {
@ -130,8 +148,12 @@ open class ListHabitsBehavior @Inject constructor(
}
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(
CreateRepetitionCommand(habitList, habit, timestamp, value, notes)
RefreshParentGroupCommand(habit, habitGroupList)
)
if (value == YES_MANUAL) screen.showConfetti(habit.color, x, y)
}
@ -178,6 +200,7 @@ open class ListHabitsBehavior @Inject constructor(
interface Screen {
fun showHabitScreen(h: Habit)
fun showHabitGroupScreen(hgr: HabitGroup)
fun showIntroScreen()
fun showMessage(m: Message)
fun showNumberPopup(

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

@ -18,12 +18,20 @@
*/
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.ChangeHabitColorCommand
import org.isoron.uhabits.core.commands.ChangeHabitGroupColorCommand
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.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.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.PaletteColor
import org.isoron.uhabits.core.ui.callbacks.OnColorPickedCallback
@ -32,33 +40,67 @@ import javax.inject.Inject
class ListHabitsSelectionMenuBehavior @Inject constructor(
private val habitList: HabitList,
private val habitGroupList: HabitGroupList,
private val screen: Screen,
private val adapter: Adapter,
var commandRunner: CommandRunner
) {
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
}
fun canEdit(): Boolean {
return adapter.getSelected().size == 1
return (adapter.getSelectedHabits().size + adapter.getSelectedHabitGroups().size == 1)
}
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
}
fun areSubHabits(): Boolean {
if (adapter.getSelectedHabitGroups().isNotEmpty()) return false
return (adapter.getSelectedHabits().all { it.isSubHabit() })
}
fun areHabits(): Boolean {
return adapter.getSelectedHabitGroups().isEmpty()
}
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()
}
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 ->
commandRunner.run(ChangeHabitColorCommand(habitList, adapter.getSelected(), selectedColor))
commandRunner.run(
ChangeHabitColorCommand(
habitList,
adapter.getSelectedHabits(),
selectedColor
)
)
commandRunner.run(
ChangeHabitGroupColorCommand(
habitGroupList,
adapter.getSelectedHabitGroups(),
selectedColor
)
)
adapter.clearSelection()
}
}
@ -66,29 +108,58 @@ class ListHabitsSelectionMenuBehavior @Inject constructor(
fun onDeleteHabits() {
screen.showDeleteConfirmationScreen(
{
adapter.performRemove(adapter.getSelected())
commandRunner.run(DeleteHabitsCommand(habitList, adapter.getSelected()))
adapter.performRemove(adapter.getSelectedHabits())
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.getSelected().size
adapter.getSelectedHabits().size + adapter.getSelectedHabitGroups().size
)
}
fun onEditHabits() {
val selected = adapter.getSelected()
if (selected.isNotEmpty()) screen.showEditHabitsScreen(selected)
val selected = adapter.getSelectedHabits()
if (selected.isNotEmpty()) {
screen.showEditHabitsScreen(selected)
} else {
val selectedGroup = adapter.getSelectedHabitGroups()
screen.showEditHabitGroupScreen(selectedGroup)
}
adapter.clearSelection()
}
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()
}
interface Adapter {
fun clearSelection()
fun getSelected(): List<Habit>
fun getSelectedHabits(): List<Habit>
fun getSelectedHabitGroups(): List<HabitGroup>
fun performRemove(selected: List<Habit>)
fun performRemoveHabitGroup(selected: List<HabitGroup>)
}
interface Screen {
@ -103,5 +174,8 @@ class ListHabitsSelectionMenuBehavior @Inject constructor(
)
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.Habit
import org.isoron.uhabits.core.models.HabitGroup
import org.isoron.uhabits.core.models.PaletteColor
import org.isoron.uhabits.core.models.groupedSum
import org.isoron.uhabits.core.preferences.Preferences
@ -74,6 +75,53 @@ class BarCardPresenter(
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) {

@ -20,10 +20,10 @@
package org.isoron.uhabits.core.ui.screens.habits.show.views
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.Timestamp
import org.isoron.uhabits.core.ui.views.Theme
import java.util.HashMap
data class FrequencyCardState(
val color: PaletteColor,
@ -42,11 +42,57 @@ class FrequencyCardPresenter {
) = FrequencyCardState(
color = habit.color,
isNumerical = habit.isNumerical,
frequency = habit.originalEntries.computeWeekdayFrequency(
frequency = habit.computedEntries.computeWeekdayFrequency(
isNumerical = habit.isNumerical
),
firstWeekday = firstWeekday,
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
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.HabitGroup
data class NotesCardState(
val description: String
@ -30,5 +31,9 @@ class NotesCardPresenter {
fun buildState(habit: Habit) = NotesCardState(
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.Habit
import org.isoron.uhabits.core.models.HabitGroup
import org.isoron.uhabits.core.models.PaletteColor
import org.isoron.uhabits.core.ui.views.Theme
import org.isoron.uhabits.core.utils.DateUtils
@ -57,5 +58,27 @@ class OverviewCardPresenter {
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