diff --git a/uhabits-android/src/androidTest/assets/views/habits/list/NumberButtonView/render_at_most_above.png b/uhabits-android/src/androidTest/assets/views/habits/list/NumberButtonView/render_at_most_above.png new file mode 100644 index 000000000..4cea80498 Binary files /dev/null and b/uhabits-android/src/androidTest/assets/views/habits/list/NumberButtonView/render_at_most_above.png differ diff --git a/uhabits-android/src/androidTest/assets/views/habits/list/NumberButtonView/render_at_most_below.png b/uhabits-android/src/androidTest/assets/views/habits/list/NumberButtonView/render_at_most_below.png new file mode 100644 index 000000000..ac4c0f5c9 Binary files /dev/null and b/uhabits-android/src/androidTest/assets/views/habits/list/NumberButtonView/render_at_most_below.png differ diff --git a/uhabits-android/src/androidTest/assets/views/habits/list/NumberButtonView/render_at_most_between.png b/uhabits-android/src/androidTest/assets/views/habits/list/NumberButtonView/render_at_most_between.png new file mode 100644 index 000000000..12063c425 Binary files /dev/null and b/uhabits-android/src/androidTest/assets/views/habits/list/NumberButtonView/render_at_most_between.png differ diff --git a/uhabits-android/src/androidTest/assets/views/habits/list/NumberButtonView/render_zero.png b/uhabits-android/src/androidTest/assets/views/habits/list/NumberButtonView/render_zero.png index 5f6bf8c65..a405c0092 100644 Binary files a/uhabits-android/src/androidTest/assets/views/habits/list/NumberButtonView/render_zero.png and b/uhabits-android/src/androidTest/assets/views/habits/list/NumberButtonView/render_zero.png differ diff --git a/uhabits-android/src/androidTest/assets/views/habits/list/NumberPanelView/render.png b/uhabits-android/src/androidTest/assets/views/habits/list/NumberPanelView/render.png index 22584afb3..a8571c3fa 100644 Binary files a/uhabits-android/src/androidTest/assets/views/habits/list/NumberPanelView/render.png and b/uhabits-android/src/androidTest/assets/views/habits/list/NumberPanelView/render.png differ diff --git a/uhabits-android/src/androidTest/java/org/isoron/uhabits/activities/habits/list/views/NumberButtonViewTest.kt b/uhabits-android/src/androidTest/java/org/isoron/uhabits/activities/habits/list/views/NumberButtonViewTest.kt index 6f1923aaa..75166bfab 100644 --- a/uhabits-android/src/androidTest/java/org/isoron/uhabits/activities/habits/list/views/NumberButtonViewTest.kt +++ b/uhabits-android/src/androidTest/java/org/isoron/uhabits/activities/habits/list/views/NumberButtonViewTest.kt @@ -24,6 +24,7 @@ import androidx.test.filters.MediumTest import org.hamcrest.CoreMatchers.equalTo import org.hamcrest.MatcherAssert.assertThat import org.isoron.uhabits.BaseViewTest +import org.isoron.uhabits.core.models.NumericalHabitType import org.isoron.uhabits.utils.PaletteUtils import org.junit.Before import org.junit.Test @@ -42,6 +43,7 @@ class NumberButtonViewTest : BaseViewTest() { super.setUp() view = component.getNumberButtonViewFactory().create().apply { units = "steps" + targetType = NumericalHabitType.AT_LEAST threshold = 100.0 color = PaletteUtils.getAndroidTestColor(8) onEdit = { edited = true } @@ -74,10 +76,10 @@ class NumberButtonViewTest : BaseViewTest() { } @Test - fun testRender_emptyUnits() { + fun testRender_atMostAboveThreshold() { view.value = 500.0 - view.units = "" - assertRenders(view, "$PATH/render_unitless.png") + view.targetType = NumericalHabitType.AT_MOST + assertRenders(view, "$PATH/render_at_most_above.png") } @Test @@ -86,12 +88,33 @@ class NumberButtonViewTest : BaseViewTest() { assertRenders(view, "$PATH/render_below.png") } + @Test + fun testRender_atMostBetweenThresholds() { + view.value = 110.0 + view.targetType = NumericalHabitType.AT_MOST + assertRenders(view, "$PATH/render_at_most_between.png") + } + @Test fun testRender_zero() { view.value = 0.0 assertRenders(view, "$PATH/render_zero.png") } + @Test + fun testRender_atMostBelowThreshold() { + view.value = 0.0 + view.targetType = NumericalHabitType.AT_MOST + assertRenders(view, "$PATH/render_at_most_below.png") + } + + @Test + fun testRender_emptyUnits() { + view.value = 500.0 + view.units = "" + assertRenders(view, "$PATH/render_unitless.png") + } + @Test fun testClick_shortToggleDisabled() { prefs.isShortToggleEnabled = false diff --git a/uhabits-android/src/androidTest/java/org/isoron/uhabits/activities/habits/list/views/NumberPanelViewTest.kt b/uhabits-android/src/androidTest/java/org/isoron/uhabits/activities/habits/list/views/NumberPanelViewTest.kt index bd046acc7..da0a2eb8a 100644 --- a/uhabits-android/src/androidTest/java/org/isoron/uhabits/activities/habits/list/views/NumberPanelViewTest.kt +++ b/uhabits-android/src/androidTest/java/org/isoron/uhabits/activities/habits/list/views/NumberPanelViewTest.kt @@ -24,6 +24,7 @@ import androidx.test.filters.MediumTest import org.hamcrest.CoreMatchers.equalTo import org.hamcrest.MatcherAssert.assertThat import org.isoron.uhabits.BaseViewTest +import org.isoron.uhabits.core.models.NumericalHabitType import org.isoron.uhabits.core.models.Timestamp import org.isoron.uhabits.utils.PaletteUtils import org.junit.After @@ -55,6 +56,7 @@ class NumberPanelViewTest : BaseViewTest() { buttonCount = 4 color = PaletteUtils.getAndroidTestColor(7) units = "steps" + targetType = NumericalHabitType.AT_LEAST threshold = 5000.0 } view.onAttachedToWindow() diff --git a/uhabits-android/src/androidTest/java/org/isoron/uhabits/activities/habits/show/views/SubtitleCardViewTest.kt b/uhabits-android/src/androidTest/java/org/isoron/uhabits/activities/habits/show/views/SubtitleCardViewTest.kt index a43543936..3323f78df 100644 --- a/uhabits-android/src/androidTest/java/org/isoron/uhabits/activities/habits/show/views/SubtitleCardViewTest.kt +++ b/uhabits-android/src/androidTest/java/org/isoron/uhabits/activities/habits/show/views/SubtitleCardViewTest.kt @@ -53,8 +53,6 @@ class SubtitleCardViewTest : BaseViewTest() { isNumerical = false, question = "Did you meditate this morning?", reminder = Reminder(8, 30, EVERY_DAY), - unit = "", - targetValue = 0.0, theme = LightTheme(), ) ) diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/common/dialogs/HistoryEditorDialog.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/common/dialogs/HistoryEditorDialog.kt index fcad26125..5edae637f 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/common/dialogs/HistoryEditorDialog.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/common/dialogs/HistoryEditorDialog.kt @@ -62,6 +62,7 @@ class HistoryEditorDialog : AppCompatDialogFragment(), CommandRunner.Listener { firstWeekday = preferences.firstWeekday, paletteColor = habit.color, series = emptyList(), + defaultSquare = HistoryChart.Square.OFF, theme = themeSwitcher.currentTheme, today = DateUtils.getTodayWithOffset().toLocalDate(), onDateClickedListener = onDateClickedListener ?: OnDateClickedListener { }, @@ -101,6 +102,7 @@ class HistoryEditorDialog : AppCompatDialogFragment(), CommandRunner.Listener { theme = LightTheme() ) chart?.series = model.series + chart?.defaultSquare = model.defaultSquare dataView.postInvalidate() } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/edit/EditHabitActivity.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/edit/EditHabitActivity.kt index 1a02d8845..572917d10 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/edit/EditHabitActivity.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/edit/EditHabitActivity.kt @@ -88,6 +88,7 @@ class EditHabitActivity : AppCompatActivity() { var reminderHour = -1 var reminderMin = -1 var reminderDays: WeekdayList = WeekdayList.EVERY_DAY + var targetType = NumericalHabitType.AT_LEAST override fun onCreate(state: Bundle?) { super.onCreate(state) @@ -107,6 +108,7 @@ class EditHabitActivity : AppCompatActivity() { color = habit.color freqNum = habit.frequency.numerator freqDen = habit.frequency.denominator + targetType = habit.targetType habit.reminder?.let { reminderHour = it.hour reminderMin = it.minute @@ -138,6 +140,7 @@ class EditHabitActivity : AppCompatActivity() { HabitType.YES_NO -> { binding.unitOuterBox.visibility = View.GONE binding.targetOuterBox.visibility = View.GONE + binding.targetTypeOuterBox.visibility = View.GONE } HabitType.NUMERICAL -> { binding.nameInput.hint = getString(R.string.measurable_short_example) @@ -172,6 +175,23 @@ class EditHabitActivity : AppCompatActivity() { dialog.show(supportFragmentManager, "frequencyPicker") } + populateTargetType() + binding.targetTypePicker.setOnClickListener { + val builder = AlertDialog.Builder(this) + val arrayAdapter = ArrayAdapter(this, android.R.layout.select_dialog_item) + arrayAdapter.add(getString(R.string.target_type_at_least)) + arrayAdapter.add(getString(R.string.target_type_at_most)) + builder.setAdapter(arrayAdapter) { dialog, which -> + targetType = when (which) { + 0 -> NumericalHabitType.AT_LEAST + else -> NumericalHabitType.AT_MOST + } + populateTargetType() + dialog.dismiss() + } + builder.show() + } + binding.numericalFrequencyPicker.setOnClickListener { val builder = AlertDialog.Builder(this) val arrayAdapter = ArrayAdapter(this, android.R.layout.select_dialog_item) @@ -262,7 +282,7 @@ class EditHabitActivity : AppCompatActivity() { habit.frequency = Frequency(freqNum, freqDen) if (habitType == HabitType.NUMERICAL) { habit.targetValue = targetInput.text.toString().toDouble() - habit.targetType = NumericalHabitType.AT_LEAST + habit.targetType = targetType habit.unit = unitInput.text.trim().toString() } habit.type = habitType @@ -324,6 +344,13 @@ class EditHabitActivity : AppCompatActivity() { } } + private fun populateTargetType() { + binding.targetTypePicker.text = when (targetType) { + NumericalHabitType.AT_MOST -> getString(R.string.target_type_at_most) + else -> getString(R.string.target_type_at_least) + } + } + private fun updateColors() { androidColor = themeSwitcher.currentTheme.color(color).toInt() binding.colorButton.backgroundTintList = ColorStateList.valueOf(androidColor) diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardView.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardView.kt index c59b61ec1..d6e7a2ad3 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardView.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardView.kt @@ -236,6 +236,7 @@ class HabitCardView( numberPanel.apply { color = c units = h.unit + targetType = h.targetType threshold = h.targetValue visibility = when (h.isNumerical) { true -> View.VISIBLE diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/NumberButtonView.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/NumberButtonView.kt index 3e48ed1a6..405bf606d 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/NumberButtonView.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/NumberButtonView.kt @@ -29,13 +29,15 @@ import android.view.View import android.view.View.OnClickListener import android.view.View.OnLongClickListener import org.isoron.uhabits.R +import org.isoron.uhabits.core.models.NumericalHabitType import org.isoron.uhabits.core.preferences.Preferences import org.isoron.uhabits.inject.ActivityContext import org.isoron.uhabits.utils.InterfaceUtils.getDimension -import org.isoron.uhabits.utils.StyledResources import org.isoron.uhabits.utils.dim import org.isoron.uhabits.utils.getFontAwesome import org.isoron.uhabits.utils.showMessage +import org.isoron.uhabits.utils.sres +import java.lang.Double.max import java.text.DecimalFormat import javax.inject.Inject @@ -88,6 +90,12 @@ class NumberButtonView( invalidate() } + var targetType = NumericalHabitType.AT_LEAST + set(value) { + field = value + invalidate() + } + var units = "" set(value) { field = value @@ -127,7 +135,6 @@ class NumberButtonView( private val em: Float private val rect: RectF = RectF() - private val sr = StyledResources(context) private val lowContrast: Int private val mediumContrast: Int @@ -148,15 +155,23 @@ class NumberButtonView( init { em = pNumber.measureText("m") - lowContrast = sr.getColor(R.attr.contrast40) - mediumContrast = sr.getColor(R.attr.contrast60) + lowContrast = sres.getColor(R.attr.contrast40) + mediumContrast = sres.getColor(R.attr.contrast60) } fun draw(canvas: Canvas) { - val activeColor = when { - value <= 0.0 -> lowContrast - value < threshold -> mediumContrast - else -> color + var activeColor = if (targetType == NumericalHabitType.AT_LEAST) { + when { + value < 0.0 && preferences.areQuestionMarksEnabled -> lowContrast + max(0.0, value) >= threshold -> color + else -> mediumContrast + } + } else { + when { + value < 0.0 && preferences.areQuestionMarksEnabled -> lowContrast + value <= threshold -> color + else -> mediumContrast + } } val label: String diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/NumberPanelView.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/NumberPanelView.kt index 491656a77..0a5339ce0 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/NumberPanelView.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/NumberPanelView.kt @@ -20,6 +20,7 @@ package org.isoron.uhabits.activities.habits.list.views import android.content.Context +import org.isoron.uhabits.core.models.NumericalHabitType import org.isoron.uhabits.core.models.Timestamp import org.isoron.uhabits.core.preferences.Preferences import org.isoron.uhabits.core.utils.DateUtils @@ -47,6 +48,12 @@ class NumberPanelView( setupButtons() } + var targetType = NumericalHabitType.AT_LEAST + set(value) { + field = value + setupButtons() + } + var threshold = 0.0 set(value) { field = value @@ -84,6 +91,7 @@ class NumberPanelView( else -> 0.0 } button.color = color + button.targetType = targetType button.threshold = threshold button.units = units button.onEdit = { onEdit(timestamp) } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/views/HistoryCardView.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/views/HistoryCardView.kt index c60e84bd1..f429e1718 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/views/HistoryCardView.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/views/HistoryCardView.kt @@ -43,6 +43,7 @@ class HistoryCardView(context: Context, attrs: AttributeSet) : LinearLayout(cont theme = state.theme, dateFormatter = JavaLocalDateFormatter(Locale.getDefault()), series = state.series, + defaultSquare = state.defaultSquare, firstWeekday = state.firstWeekday, ) binding.chart.postInvalidate() diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/views/SubtitleCardView.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/views/SubtitleCardView.kt index 49c67aae1..5bbfb46d6 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/views/SubtitleCardView.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/views/SubtitleCardView.kt @@ -28,6 +28,7 @@ import org.isoron.platform.gui.toInt import org.isoron.uhabits.R import org.isoron.uhabits.activities.habits.edit.formatFrequency import org.isoron.uhabits.activities.habits.list.views.toShortString +import org.isoron.uhabits.core.models.NumericalHabitType import org.isoron.uhabits.core.ui.screens.habits.show.views.SubtitleCardState import org.isoron.uhabits.databinding.ShowHabitSubtitleBinding import org.isoron.uhabits.utils.InterfaceUtils @@ -65,7 +66,12 @@ class SubtitleCardView(context: Context, attrs: AttributeSet) : LinearLayout(con binding.questionLabel.visibility = View.VISIBLE binding.targetIcon.visibility = View.VISIBLE binding.targetText.visibility = View.VISIBLE - if (!state.isNumerical) { + if (state.isNumerical) { + binding.targetIcon.text = when (state.targetType) { + NumericalHabitType.AT_LEAST -> resources.getString(R.string.fa_arrow_circle_up) + else -> resources.getString(R.string.fa_arrow_circle_down) + } + } else { binding.targetIcon.visibility = View.GONE binding.targetText.visibility = View.GONE } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/widgets/HistoryWidget.kt b/uhabits-android/src/main/java/org/isoron/uhabits/widgets/HistoryWidget.kt index 2e26a487b..c8d4dce44 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/widgets/HistoryWidget.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/widgets/HistoryWidget.kt @@ -56,7 +56,9 @@ class HistoryWidget( theme = WidgetTheme(), ) (widgetView.dataView as AndroidDataView).apply { - (this.view as HistoryChart).series = model.series + val historyChart = (this.view as HistoryChart) + historyChart.series = model.series + historyChart.defaultSquare = model.defaultSquare } } @@ -71,6 +73,7 @@ class HistoryWidget( dateFormatter = JavaLocalDateFormatter(Locale.getDefault()), firstWeekday = prefs.firstWeekday, series = listOf(), + defaultSquare = HistoryChart.Square.OFF, ) } ).apply { diff --git a/uhabits-android/src/main/res/layout/activity_edit_habit.xml b/uhabits-android/src/main/res/layout/activity_edit_habit.xml index b3a33c373..75dddb551 100644 --- a/uhabits-android/src/main/res/layout/activity_edit_habit.xml +++ b/uhabits-android/src/main/res/layout/activity_edit_habit.xml @@ -167,6 +167,7 @@ android:hint="@string/measurable_units_example"/> + + + + + + + + + diff --git a/uhabits-android/src/main/res/layout/show_habit_subtitle.xml b/uhabits-android/src/main/res/layout/show_habit_subtitle.xml index f8f3924bc..7361b4565 100644 --- a/uhabits-android/src/main/res/layout/show_habit_subtitle.xml +++ b/uhabits-android/src/main/res/layout/show_habit_subtitle.xml @@ -47,7 +47,6 @@ android:id="@+id/targetIcon" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:text="@string/fa_arrow_circle_up" android:textColor="?attr/contrast60" android:textSize="16sp" /> diff --git a/uhabits-android/src/main/res/values/fontawesome.xml b/uhabits-android/src/main/res/values/fontawesome.xml index 917cf0d73..0ff190382 100644 --- a/uhabits-android/src/main/res/values/fontawesome.xml +++ b/uhabits-android/src/main/res/values/fontawesome.xml @@ -21,6 +21,7 @@ + @@ -181,7 +182,6 @@ - diff --git a/uhabits-android/src/main/res/values/strings.xml b/uhabits-android/src/main/res/values/strings.xml index 3aec3b72d..13767b3df 100644 --- a/uhabits-android/src/main/res/values/strings.xml +++ b/uhabits-android/src/main/res/values/strings.xml @@ -184,6 +184,9 @@ Change value Calendar Unit + Target Type + At least + At most e.g. Did you exercise today? Question Target diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/commands/CreateHabitCommand.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/commands/CreateHabitCommand.kt index 49e3bae7a..8ff21f407 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/commands/CreateHabitCommand.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/commands/CreateHabitCommand.kt @@ -31,5 +31,6 @@ data class CreateHabitCommand( val habit = modelFactory.buildHabit() habit.copyFrom(model) habitList.add(habit) + habit.recompute() } } diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/Habit.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/Habit.kt index 2d354e7b8..20c3d7949 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/Habit.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/Habit.kt @@ -88,14 +88,16 @@ data class Habit( isNumerical = isNumerical, ) - val to = DateUtils.getTodayWithOffset().plus(30) + val today = DateUtils.getTodayWithOffset() + val to = today.plus(30) val entries = computedEntries.getKnown() - var from = entries.lastOrNull()?.timestamp ?: to + var from = entries.lastOrNull()?.timestamp ?: today if (from.isNewerThan(to)) from = to scores.recompute( frequency = frequency, isNumerical = isNumerical, + numericalHabitType = targetType, targetValue = targetValue, computedEntries = computedEntries, from = from, diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/ScoreList.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/ScoreList.kt index b5dff17de..23bd88fd1 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/ScoreList.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/ScoreList.kt @@ -68,19 +68,19 @@ class ScoreList { fun recompute( frequency: Frequency, isNumerical: Boolean, + numericalHabitType: NumericalHabitType, targetValue: Double, computedEntries: EntryList, from: Timestamp, to: Timestamp, ) { map.clear() - if (computedEntries.getKnown().isEmpty()) return - if (from.isNewerThan(to)) return var rollingSum = 0.0 var numerator = frequency.numerator var denominator = frequency.denominator val freq = frequency.toDouble() val values = computedEntries.getByInterval(from, to).map { it.value }.toIntArray() + val isAtMost = numericalHabitType == NumericalHabitType.AT_MOST // For non-daily boolean habits, we double the numerator and the denominator to smooth // out irregular repetition schedules (for example, weekly habits performed on different @@ -90,19 +90,29 @@ class ScoreList { denominator *= 2 } - var previousValue = 0.0 + var previousValue = if (isNumerical && isAtMost) 1.0 else 0.0 for (i in values.indices) { val offset = values.size - i - 1 if (isNumerical) { rollingSum += max(0, values[offset]) if (offset + denominator < values.size) { - rollingSum -= values[offset + denominator] + rollingSum -= max(0, values[offset + denominator]) } - val percentageCompleted = if (targetValue > 0) { - min(1.0, rollingSum / 1000 / targetValue) + + val normalizedRollingSum = rollingSum / 1000 + val percentageCompleted = if (!isAtMost) { + if (targetValue > 0) + min(1.0, normalizedRollingSum / targetValue) + else + 1.0 } else { - 1.0 + if (targetValue > 0) { + (1 - ((normalizedRollingSum - targetValue) / targetValue)).coerceIn(0.0, 1.0) + } else { + if (normalizedRollingSum > 0) 0.0 else 1.0 + } } + previousValue = compute(freq, previousValue, percentageCompleted) } else { if (values[offset] == Entry.YES_MANUAL) { diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/test/HabitFixtures.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/test/HabitFixtures.kt index 38a34f0e1..fffe4ac85 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/test/HabitFixtures.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/test/HabitFixtures.kt @@ -50,6 +50,19 @@ class HabitFixtures(private val modelFactory: ModelFactory, private val habitLis return habit } + fun createEmptyNumericalHabit(targetType: NumericalHabitType): Habit { + val habit = modelFactory.buildHabit() + habit.type = HabitType.NUMERICAL + habit.name = "Run" + habit.question = "How many miles did you run today?" + habit.unit = "miles" + habit.targetType = targetType + habit.targetValue = 2.0 + habit.color = PaletteColor(1) + saveIfSQLite(habit) + return habit + } + fun createLongHabit(): Habit { val habit = createEmptyHabit() habit.frequency = Frequency(3, 7) diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/show/views/HistoryCard.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/show/views/HistoryCard.kt index bd201e0c5..3ce83c8b3 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/show/views/HistoryCard.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/show/views/HistoryCard.kt @@ -29,6 +29,7 @@ import org.isoron.uhabits.core.models.Entry.Companion.YES_AUTO import org.isoron.uhabits.core.models.Entry.Companion.YES_MANUAL import org.isoron.uhabits.core.models.Habit import org.isoron.uhabits.core.models.HabitList +import org.isoron.uhabits.core.models.NumericalHabitType import org.isoron.uhabits.core.models.PaletteColor import org.isoron.uhabits.core.models.Timestamp import org.isoron.uhabits.core.preferences.Preferences @@ -44,6 +45,7 @@ data class HistoryCardState( val color: PaletteColor, val firstWeekday: DayOfWeek, val series: List, + val defaultSquare: HistoryChart.Square, val theme: Theme, val today: LocalDate, ) @@ -105,12 +107,19 @@ class HistoryCardPresenter( val oldest = habit.computedEntries.getKnown().lastOrNull()?.timestamp ?: today val entries = habit.computedEntries.getByInterval(oldest, today) val series = if (habit.isNumerical) { - entries.map { - Entry(it.timestamp, max(0, it.value)) - }.map { - when (it.value) { - 0 -> HistoryChart.Square.OFF - else -> HistoryChart.Square.ON + if (habit.targetType == NumericalHabitType.AT_LEAST) { + entries.map { + when (max(0, it.value)) { + 0 -> HistoryChart.Square.OFF + else -> HistoryChart.Square.ON + } + } + } else { + entries.map { + when { + max(0.0, it.value / 1000.0) <= habit.targetValue -> HistoryChart.Square.ON + else -> HistoryChart.Square.OFF + } } } } else { @@ -123,6 +132,10 @@ class HistoryCardPresenter( } } } + val defaultSquare = if (habit.isNumerical && habit.targetType == NumericalHabitType.AT_MOST) + HistoryChart.Square.ON + else + HistoryChart.Square.OFF return HistoryCardState( color = habit.color, @@ -130,6 +143,7 @@ class HistoryCardPresenter( today = today.toLocalDate(), theme = theme, series = series, + defaultSquare = defaultSquare ) } } diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/show/views/SubtitleCard.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/show/views/SubtitleCard.kt index 525f8fc7c..4cbbd74ec 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/show/views/SubtitleCard.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/show/views/SubtitleCard.kt @@ -21,6 +21,7 @@ package org.isoron.uhabits.core.ui.screens.habits.show.views import org.isoron.uhabits.core.models.Frequency import org.isoron.uhabits.core.models.Habit +import org.isoron.uhabits.core.models.NumericalHabitType import org.isoron.uhabits.core.models.PaletteColor import org.isoron.uhabits.core.models.Reminder import org.isoron.uhabits.core.ui.views.Theme @@ -31,8 +32,9 @@ data class SubtitleCardState( val isNumerical: Boolean, val question: String, val reminder: Reminder?, - val targetValue: Double, - val unit: String, + val targetValue: Double = 0.0, + val targetType: NumericalHabitType = NumericalHabitType.AT_LEAST, + val unit: String = "", val theme: Theme, ) @@ -48,6 +50,7 @@ class SubtitleCardPresenter { question = habit.question, reminder = habit.reminder, targetValue = habit.targetValue, + targetType = habit.targetType, unit = habit.unit, theme = theme, ) diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/views/HistoryChart.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/views/HistoryChart.kt index 699cc2ee6..f0da7a196 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/views/HistoryChart.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/views/HistoryChart.kt @@ -41,6 +41,7 @@ class HistoryChart( var firstWeekday: DayOfWeek, var paletteColor: PaletteColor, var series: List, + var defaultSquare: Square, var theme: Theme, var today: LocalDate, var onDateClickedListener: OnDateClickedListener = OnDateClickedListener { }, @@ -189,7 +190,7 @@ class HistoryChart( offset: Int, ) { - val value = if (offset >= series.size) Square.OFF else series[offset] + val value = if (offset >= series.size) defaultSquare else series[offset] val squareColor: Color val color = theme.color(paletteColor.paletteIndex) squareColor = when (value) { diff --git a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/models/ScoreListTest.kt b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/models/ScoreListTest.kt index f78af6d21..6ca3e2806 100644 --- a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/models/ScoreListTest.kt +++ b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/models/ScoreListTest.kt @@ -28,14 +28,36 @@ import org.junit.Before import org.junit.Test import java.util.ArrayList -class ScoreListTest : BaseUnitTest() { - private lateinit var habit: Habit - private lateinit var today: Timestamp +open class BaseScoreListTest : BaseUnitTest() { + protected lateinit var habit: Habit + protected lateinit var today: Timestamp + @Before @Throws(Exception::class) override fun setUp() { super.setUp() today = getToday() + } + + protected fun checkScoreValues(expectedValues: DoubleArray) { + var current = today + val scores = habit.scores + for (expectedValue in expectedValues) { + assertThat(scores[current].value, IsCloseTo.closeTo(expectedValue, E)) + current = current.minus(1) + } + } + + companion object { + const val E = 1e-6 + } +} + +class YesNoScoreListTest : BaseScoreListTest() { + @Before + @Throws(Exception::class) + override fun setUp() { + super.setUp() habit = fixtures.createEmptyHabit() } @@ -122,14 +144,6 @@ class ScoreListTest : BaseUnitTest() { checkScoreValues(expectedValues) } - @Test - fun test_withZeroTarget() { - habit = fixtures.createNumericalHabit() - habit.targetValue = 0.0 - habit.recompute() - assertTrue(habit.scores[today].value.isFinite()) - } - @Test fun test_imperfectNonDaily() { // If the habit should be performed 3 times per week and the user misses 1 repetition @@ -255,17 +269,204 @@ class ScoreListTest : BaseUnitTest() { val entries = habit.originalEntries entries.add(Entry(today.minus(day), Entry.SKIP)) } +} - private fun checkScoreValues(expectedValues: DoubleArray) { - var current = today - val scores = habit.scores - for (expectedValue in expectedValues) { - assertThat(scores[current].value, IsCloseTo.closeTo(expectedValue, E)) - current = current.minus(1) - } +open class NumericalScoreListTest : BaseScoreListTest() { + protected fun addEntry(day: Int, value: Int) { + val entries = habit.originalEntries + entries.add(Entry(today.minus(day), value)) } - companion object { - private const val E = 1e-6 + protected fun addEntries(from: Int, to: Int, value: Int) { + val entries = habit.originalEntries + for (i in from until to) entries.add(Entry(today.minus(i), value)) + habit.recompute() + } +} + +class NumericalAtLeastScoreListTest : NumericalScoreListTest() { + @Before + @Throws(Exception::class) + override fun setUp() { + super.setUp() + habit = fixtures.createEmptyNumericalHabit(NumericalHabitType.AT_LEAST) + } + + @Test + fun test_withZeroTarget() { + habit = fixtures.createNumericalHabit() + habit.targetValue = 0.0 + habit.recompute() + assertTrue(habit.scores[today].value.isFinite()) + } + + @Test + fun test_getValue() { + addEntries(0, 20, 2000) + val expectedValues = doubleArrayOf( + 0.655747, + 0.636894, + 0.617008, + 0.596033, + 0.573910, + 0.550574, + 0.525961, + 0.500000, + 0.472617, + 0.443734, + 0.413270, + 0.381137, + 0.347244, + 0.311495, + 0.273788, + 0.234017, + 0.192067, + 0.147820, + 0.101149, + 0.051922, + 0.000000, + 0.000000, + 0.000000 + ) + checkScoreValues(expectedValues) + } + + @Test + fun test_recompute() { + assertThat(habit.scores[today].value, IsCloseTo.closeTo(0.0, E)) + addEntries(0, 2, 2000) + assertThat(habit.scores[today].value, IsCloseTo.closeTo(0.101149, E)) + habit.frequency = Frequency(1, 2) + habit.recompute() + assertThat(habit.scores[today].value, IsCloseTo.closeTo(0.072631, E)) + } + + @Test + fun shouldAchieveHighScoreInReasonableTime() { + // Daily habits should achieve at least 99% in 3 months + habit = fixtures.createEmptyNumericalHabit(NumericalHabitType.AT_LEAST) + habit.frequency = Frequency.DAILY + for (i in 0..89) addEntry(i, 2000) + habit.recompute() + assertThat(habit.scores[today].value, OrderingComparison.greaterThan(0.99)) + + // Weekly habits should achieve at least 99% in 9 months + habit = fixtures.createEmptyNumericalHabit(NumericalHabitType.AT_LEAST) + habit.frequency = Frequency.WEEKLY + for (i in 0..38) addEntry(7 * i, 2000) + habit.recompute() + assertThat(habit.scores[today].value, OrderingComparison.greaterThan(0.99)) + + // Monthly habits should achieve at least 99% in 18 months + habit.frequency = Frequency(1, 30) + for (i in 0..17) addEntry(30 * i, 2000) + habit.recompute() + assertThat(habit.scores[today].value, OrderingComparison.greaterThan(0.99)) + } + + @Test + fun shouldAchieveComparableScoreToProgress() { + addEntries(0, 500, 1000) + assertThat(habit.scores[today].value, IsCloseTo.closeTo(0.5, E)) + + addEntries(0, 500, 500) + assertThat(habit.scores[today].value, IsCloseTo.closeTo(0.25, E)) + } + + @Test + fun overeachievingIsntRelevant() { + addEntry(0, 10000000) + habit.recompute() + assertThat(habit.scores[today].value, IsCloseTo.closeTo(0.051922, E)) + } +} + +class NumericalAtMostScoreListTest : NumericalScoreListTest() { + @Before + @Throws(Exception::class) + override fun setUp() { + super.setUp() + habit = fixtures.createEmptyNumericalHabit(NumericalHabitType.AT_MOST) + } + + @Test + fun test_withZeroTarget() { + habit = fixtures.createNumericalHabit() + habit.targetType = NumericalHabitType.AT_MOST + habit.targetValue = 0.0 + habit.recompute() + assertTrue(habit.scores[today].value.isFinite()) + } + + @Test + fun test_getValue() { + addEntry(20, 1000) + addEntries(0, 20, 5000) + val expectedValues = doubleArrayOf( + 0.344253, + 0.363106, + 0.382992, + 0.403967, + 0.426090, + 0.449426, + 0.474039, + 0.500000, + 0.527383, + 0.556266, + 0.586730, + 0.618863, + 0.652756, + 0.688505, + 0.726212, + 0.765983, + 0.807933, + 0.852180, + 0.898851, + 0.948078, + 1.0, + 0.0, + 0.0 + ) + checkScoreValues(expectedValues) + } + + @Test + fun test_recompute() { + habit.recompute() + assertThat(habit.scores[today].value, IsCloseTo.closeTo(1.0, E)) + addEntries(0, 2, 5000) + assertThat(habit.scores[today].value, IsCloseTo.closeTo(0.898850, E)) + habit.frequency = Frequency(1, 2) + habit.recompute() + assertThat(habit.scores[today].value, IsCloseTo.closeTo(0.927369, E)) + } + + @Test + fun shouldAchieveComparableScoreToProgress() { + addEntries(0, 500, 3000) + assertThat(habit.scores[today].value, IsCloseTo.closeTo(0.5, E)) + + addEntries(0, 500, 3500) + assertThat(habit.scores[today].value, IsCloseTo.closeTo(0.25, E)) + } + + @Test + fun undereachievingIsntRelevant() { + addEntry(1, 10000000) + habit.recompute() + assertThat(habit.scores[today].value, IsCloseTo.closeTo(0.950773, E)) + } + + @Test + fun overeachievingIsntRelevant() { + addEntry(0, 5000) + + addEntry(1, 0) + habit.recompute() + assertThat(habit.scores[today].value, IsCloseTo.closeTo(0.948077, E)) + + addEntry(1, 1000) + habit.recompute() + assertThat(habit.scores[today].value, IsCloseTo.closeTo(0.948077, E)) } } diff --git a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/ui/views/HistoryChartTest.kt b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/ui/views/HistoryChartTest.kt index bce63bcec..a53135f9b 100644 --- a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/ui/views/HistoryChartTest.kt +++ b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/ui/views/HistoryChartTest.kt @@ -49,6 +49,7 @@ class HistoryChartTest { dateFormatter = JavaLocalDateFormatter(Locale.US), firstWeekday = SUNDAY, onDateClickedListener = dateClickedListener, + defaultSquare = OFF, series = listOf( 2, // today 2, 1, 2, 1, 2, 1, 2,